diff --git a/.dockerignore b/.dockerignore index 820a5e62a..d754a0da6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,84 @@ -# .dockerignore +**/build/ +**/target/ +**/.gradle/ +**/out/ +**/bin/ -.git -.idea -.gradle \ No newline at end of file +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo + +.DS_Store +Thumbs.db +desktop.ini + +.env +.env.* +properties.env +!docker/env.example + +logs/ +*.log +*.log.* + +tmp/ +temp/ +*.tmp +*.temp + +*.md +!README.md +docs/ +images/ +*.png +*.jpg +*.jpeg +*.gif + +**/test-results/ +**/coverage/ +**/*test.properties +**/jacoco/ + +.git/ +.gitignore +.gitattributes + +.github/ +.gitlab-ci.yml +.travis.yml +.circleci/ +Jenkinsfile + +grafana/ +prometheus/ +ecs-task-definitions/ +loadtest-package/ +k8s/ +terraform/ + +docker-compose*.yml +docker/.dockerignore +!Dockerfile +!*/Dockerfile + +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + +.mvn/ +mvnw +mvnw.cmd + +!gradle/ +!gradlew +!gradlew.bat + +*/docker-compose.yml diff --git a/.gitignore b/.gitignore index 2b1defd01..18e526f76 100644 --- a/.gitignore +++ b/.gitignore @@ -1,45 +1,78 @@ -HELP.md -.gradle -build/classes -build/generated -build/reports -build/resolvedMainClassName -build/test-results -build/tmp -build/resources +# Gradle 빌드 결과물 +.gradle/ +**/build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ -### STS ### +# Maven 빌드 결과물 (혹시 사용하는 경우) +target/ +!**/src/main/**/target/ +!**/src/test/**/target/ + +# IDE 파일들 +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# Eclipse STS .apt_generated .classpath .factorypath .project -.settings +.settings/ .springBeans .sts4-cache bin/ !**/src/main/**/bin/ !**/src/test/**/bin/ -### IntelliJ IDEA ### -.idea -*.iws -*.iml -*.ipr -out/ -!**/src/main/**/out/ -!**/src/test/**/out/ +# VS Code +.vscode/ -### NetBeans ### +# NetBeans /nbproject/private/ /nbbuild/ /dist/ /nbdist/ /.nb-gradle/ -### VS Code ### -.vscode/ +# OS 파일들 +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# 로그 파일들 +logs/ +*.log +*.log.* +hs_err_pid* + +# 임시 파일들 +*.tmp +*.temp +tmp/ +temp/ + +# 환경 설정 파일들 (중요!) +.env +.env.* +properties.env +application-*.properties +!application.yml +!application.properties +!application-test.yml -*.env \ No newline at end of file +# Firebase 자격 증명 파일들 +**/firebase-*.json +firebase-service-account.json +firebase-survey-account.json diff --git a/build.gradle b/build.gradle index 7b2dd9d09..75b968b6b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,101 +1,39 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.3' - id 'io.spring.dependency-management' version '1.1.7' + id 'org.springframework.boot' version '3.5.3' apply false + id 'io.spring.dependency-management' version '1.1.7' apply false } -group = 'com.example' -version = '0.0.1-SNAPSHOT' +subprojects { + apply plugin: 'java' + apply plugin: 'java-library' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' -java { - toolchain { - languageVersion = JavaLanguageVersion.of(17) - } -} + group = 'com.example' + version = '0.0.1-SNAPSHOT' -configurations { - compileOnly { - extendsFrom annotationProcessor + java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } } -} - -repositories { - mavenCentral() - maven { url 'https://artifacts.elastic.co/maven' } -} - -dependencies { - implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.security:spring-security-test' - compileOnly 'org.projectlombok:lombok' - runtimeOnly 'org.postgresql:postgresql' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - annotationProcessor 'org.projectlombok:lombok' - // testRuntimeOnly 'com.h2database:h2' // PostgreSQL Testcontainers 사용으로 H2 비활성화 - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testImplementation 'org.springframework.security:spring-security-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - implementation 'io.jsonwebtoken:jjwt-api:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' - - implementation 'at.favre.lib:bcrypt:0.10.2' - - // query dsl - implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' - annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" - annotationProcessor("jakarta.persistence:jakarta.persistence-api") - annotationProcessor("jakarta.annotation:jakarta.annotation-api") - - // Redis , JSON 직렬화 - implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'com.fasterxml.jackson.module:jackson-module-parameter-names' - implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' - - // OAuth - annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor' - - // Testcontainers JUnit 5 지원 라이브러리 - testImplementation 'org.testcontainers:junit-jupiter:1.19.8' - // PostgreSQL 컨테이너 라이브러리 - testImplementation 'org.testcontainers:postgresql:1.19.8' - - // Actuator - implementation 'org.springframework.boot:spring-boot-starter-actuator' - - // Prometheus - implementation 'io.micrometer:micrometer-registry-prometheus' - - // Gmail SMTP - implementation 'org.springframework.boot:spring-boot-starter-mail' - - // Apache HttpClient 5 - implementation 'org.apache.httpcomponents.client5:httpclient5' - - // 카페인 캐시 - implementation 'org.springframework.boot:spring-boot-starter-cache' - implementation 'com.github.ben-manes.caffeine:caffeine' - - // MongoDB 의존성 추가 - implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' - // 테스트용 MongoDB - testImplementation 'org.testcontainers:mongodb:1.19.3' - - //AMQP - implementation 'org.springframework.boot:spring-boot-starter-amqp' - - // Elasticsearch - implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' - implementation 'co.elastic.clients:elasticsearch-java:8.15.0' + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } - //FCM - implementation 'com.google.firebase:firebase-admin:9.2.0' -} + repositories { + mavenCentral() + maven { url 'https://artifacts.elastic.co/maven' } + } -tasks.named('test') { - useJUnitPlatform() + dependencies { + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + } } diff --git a/docker/.dockerignore b/docker/.dockerignore new file mode 100644 index 000000000..fc788022b --- /dev/null +++ b/docker/.dockerignore @@ -0,0 +1,90 @@ + +**/build/ +**/target/ +**/.gradle/ +**/out/ +**/bin/ + +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo + +.DS_Store +Thumbs.db +desktop.ini + +logs/ +*.log +*.log.* + +tmp/ +temp/ +*.tmp +*.temp + + +.env +.env.* +properties.env +!docker/env.example + +*.md +!README.md +docs/ +images/ +*.png +*.jpg +*.jpeg +*.gif + + +**/test-results/ +**/coverage/ +**/*test.properties +**/jacoco/ + + +.git/ +.gitignore +.gitattributes + + +.github/ +.gitlab-ci.yml +.travis.yml +.circleci/ +Jenkinsfile + + +grafana/ +prometheus/ +ecs-task-definitions/ +loadtest-package/ +k8s/ +terraform/ + +docker-compose*.yml +.dockerignore +!Dockerfile + + +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +package-lock.json +yarn.lock + + +.mvn/ +mvnw +mvnw.cmd + + +!gradle/ +!gradlew +!gradlew.bat diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..cab5eb9c6 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,51 @@ +FROM eclipse-temurin:17-jdk AS builder + +WORKDIR /app + +COPY ../gradle gradle/ +COPY ../gradlew . +COPY ../build.gradle . +COPY ../settings.gradle . + +COPY ../shared-kernel/build.gradle shared-kernel/ +COPY ../user-module/build.gradle user-module/ +COPY ../project-module/build.gradle project-module/ +COPY ../survey-module/build.gradle survey-module/ +COPY ../participation-module/build.gradle participation-module/ +COPY ../statistic-module/build.gradle statistic-module/ +COPY ../share-module/build.gradle share-module/ +COPY ../web-app/build.gradle web-app/ + +RUN ./gradlew dependencies --no-daemon + +COPY .. . + +RUN ./gradlew :web-app:bootJar --no-daemon + +FROM eclipse-temurin:17-jre-alpine AS runtime + +RUN apk add --no-cache curl + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder /app/web-app/build/libs/*.jar app.jar + +RUN chown appuser:appgroup app.jar + +USER appuser + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+ExitOnOutOfMemoryError", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] \ No newline at end of file diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 000000000..c6c048ffd --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -0,0 +1,90 @@ +version: '3.8' + +services: + survey-api: + build: + context: .. + dockerfile: Dockerfile + target: builder + volumes: + - ../web-app/src:/app/web-app/src + - ../shared-kernel/src:/app/shared-kernel/src + - ../user-module/src:/app/user-module/src + - ../project-module/src:/app/project-module/src + - ../survey-module/src:/app/survey-module/src + - ../participation-module/src:/app/participation-module/src + - ../statistic-module/src:/app/statistic-module/src + - ../share-module/src:/app/share-module/src + environment: + - SPRING_PROFILES_ACTIVE=dev + - SPRING_DEVTOOLS_RESTART_ENABLED=true + - SPRING_JPA_HIBERNATE_DDL_AUTO=update + - SPRING_JPA_SHOW_SQL=true + - LOGGING_LEVEL_COM_EXAMPLE_SURVEYAPI=DEBUG + - LOGGING_LEVEL_ORG_SPRINGFRAMEWORK_WEB=DEBUG + ports: + - "8080:8080" + - "5005:5005" + command: > + sh -c " + ./gradlew :web-app:bootRun --args='--spring.profiles.active=dev' + " + + postgres: + ports: + - "5432:5432" + + redis: + ports: + - "6379:6379" + + mongodb: + ports: + - "27017:27017" + + rabbitmq: + ports: + - "5672:5672" + - "15672:15672" + command: > + sh -c " + rabbitmq-plugins enable rabbitmq_delayed_message_exchange && + docker-entrypoint.sh rabbitmq-server + " + + elasticsearch: + environment: + - "ES_JAVA_OPTS=-Xms256m -Xmx256m" + ports: + - "9200:9200" + command: > + sh -c " + elasticsearch-plugin list | grep -q analysis-nori || elasticsearch-plugin install analysis-nori --batch && + /usr/local/bin/docker-entrypoint.sh + " + + redis-commander: + image: rediscommander/redis-commander:latest + environment: + - REDIS_HOSTS=local:redis:6379 + ports: + - "8081:8081" + depends_on: + - redis + networks: + - survey-network + + mongo-express: + image: mongo-express:latest + environment: + - ME_CONFIG_MONGODB_ADMINUSERNAME=${MONGODB_USERNAME} + - ME_CONFIG_MONGODB_ADMINPASSWORD=${MONGODB_PASSWORD} + - ME_CONFIG_MONGODB_SERVER=mongodb + - ME_CONFIG_BASICAUTH_USERNAME=admin + - ME_CONFIG_BASICAUTH_PASSWORD=admin + ports: + - "8082:8081" + depends_on: + - mongodb + networks: + - survey-network diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml new file mode 100644 index 000000000..26bbc68fb --- /dev/null +++ b/docker/docker-compose.prod.yml @@ -0,0 +1,310 @@ +version: '3.8' + +services: + survey-api: + build: + context: .. + dockerfile: Dockerfile + target: runtime + restart: unless-stopped + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=prod + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_NAME} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=${REDIS_HOST} + - REDIS_PORT=${REDIS_PORT:-6379} + - MONGODB_HOST=${MONGODB_HOST} + - MONGODB_PORT=${MONGODB_PORT:-27017} + - MONGODB_DATABASE=${MONGODB_DATABASE} + - MONGODB_USERNAME=${MONGODB_USERNAME} + - MONGODB_PASSWORD=${MONGODB_PASSWORD} + - RABBITMQ_HOST=${RABBITMQ_HOST} + - RABBITMQ_PORT=${RABBITMQ_PORT:-5672} + - RABBITMQ_USERNAME=${RABBITMQ_USERNAME} + - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD} + - ELASTIC_URIS=${ELASTIC_URIS} + - SECRET_KEY=${SECRET_KEY} + - STATISTIC_TOKEN=${STATISTIC_TOKEN} + - API_BASE_URL=${API_BASE_URL:-http://survey-api:8080} + - KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID} + - KAKAO_REDIRECT_URL=${KAKAO_REDIRECT_URL} + - NAVER_CLIENT_ID=${NAVER_CLIENT_ID} + - NAVER_SECRET=${NAVER_SECRET} + - NAVER_REDIRECT_URL=${NAVER_REDIRECT_URL} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_SECRET=${GOOGLE_SECRET} + - GOOGLE_REDIRECT_URL=${GOOGLE_REDIRECT_URL} + - MAIL_ADDRESS=${MAIL_ADDRESS} + - MAIL_PASSWORD=${MAIL_PASSWORD} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + mongodb: + condition: service_healthy + rabbitmq: + condition: service_healthy + elasticsearch: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 15s + retries: 5 + start_period: 60s + deploy: + resources: + limits: + memory: 2G + cpus: '1.0' + reservations: + memory: 1G + cpus: '0.5' + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - survey-network + + postgres: + image: postgres:15-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256" + ports: + - "${DB_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ../web-app/src/main/resources/project.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 1G + cpus: '0.5' + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - survey-network + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 512M + cpus: '0.25' + logging: + driver: "json-file" + options: + max-size: "5m" + max-file: "3" + networks: + - survey-network + + mongodb: + image: mongo:7 + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD} + MONGO_INITDB_DATABASE: ${MONGODB_DATABASE} + ports: + - "${MONGODB_PORT:-27017}:27017" + volumes: + - mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.runCommand('ping').ok", "--quiet"] + interval: 10s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + memory: 1G + cpus: '0.5' + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - survey-network + + rabbitmq: + image: rabbitmq:3.12-management + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + RABBITMQ_DEFAULT_VHOST: / + ports: + - "${RABBITMQ_PORT:-5672}:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] + interval: 10s + timeout: 5s + retries: 5 + command: > + sh -c " + rabbitmq-plugins enable rabbitmq_delayed_message_exchange && + docker-entrypoint.sh rabbitmq-server + " + deploy: + resources: + limits: + memory: 1G + cpus: '0.5' + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - survey-network + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 + restart: unless-stopped + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms1g -Xmx1g" + ports: + - "9200:9200" + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + command: > + sh -c " + elasticsearch-plugin list | grep -q analysis-nori || elasticsearch-plugin install analysis-nori --batch && + /usr/local/bin/docker-entrypoint.sh + " + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + deploy: + resources: + limits: + memory: 2G + cpus: '1.0' + reservations: + memory: 1G + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - survey-network + + prometheus: + image: prom/prometheus:latest + restart: unless-stopped + ports: + - "9090:9090" + volumes: + - ./web-app/src/main/resources/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--storage.tsdb.retention.time=30d' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + deploy: + resources: + limits: + memory: 1G + cpus: '0.5' + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + networks: + - survey-network + + grafana: + image: grafana/grafana:latest + restart: unless-stopped + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + depends_on: + - prometheus + deploy: + resources: + limits: + memory: 512M + cpus: '0.25' + logging: + driver: "json-file" + options: + max-size: "5m" + max-file: "3" + networks: + - survey-network + +volumes: + postgres_data: + driver: local + redis_data: + driver: local + mongodb_data: + driver: local + rabbitmq_data: + driver: local + elasticsearch_data: + driver: local + prometheus_data: + driver: local + grafana_data: + driver: local + +networks: + survey-network: + driver: bridge + ipam: + config: + - subnet: 172.20.0.0/16 diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 000000000..2e75c1234 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,204 @@ +version: '3.8' + +services: + survey-api: + build: + context: .. + dockerfile: Dockerfile + target: runtime + ports: + - "8080:8080" + environment: + - SPRING_PROFILES_ACTIVE=dev + - DB_HOST=postgres + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_SCHEME:-survey_db} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=${REDIS_PORT:-6379} + - MONGODB_HOST=mongodb + - MONGODB_PORT=${MONGODB_PORT:-27017} + - MONGODB_DATABASE=${MONGODB_DATABASE} + - MONGODB_USERNAME=${MONGODB_USERNAME} + - MONGODB_PASSWORD=${MONGODB_PASSWORD} + - RABBITMQ_HOST=rabbitmq + - RABBITMQ_PORT=${RABBITMQ_PORT:-5672} + - RABBITMQ_USERNAME=${RABBITMQ_USERNAME:-admin} + - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD:-admin} + - ELASTIC_URIS=http://elasticsearch:9200 + - SECRET_KEY=${SECRET_KEY} + - STATISTIC_TOKEN=${STATISTIC_TOKEN} + - API_BASE_URL=http://survey-api:8080 + - KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID} + - KAKAO_REDIRECT_URL=${KAKAO_REDIRECT_URL} + - NAVER_CLIENT_ID=${NAVER_CLIENT_ID} + - NAVER_SECRET=${NAVER_SECRET} + - NAVER_REDIRECT_URL=${NAVER_REDIRECT_URL} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_SECRET=${GOOGLE_SECRET} + - GOOGLE_REDIRECT_URL=${GOOGLE_REDIRECT_URL} + - MAIL_ADDRESS=${MAIL_ADDRESS} + - MAIL_PASSWORD=${MAIL_PASSWORD} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + mongodb: + condition: service_healthy + rabbitmq: + condition: service_healthy + elasticsearch: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - survey-network + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: ${DB_SCHEME:-survey_db} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ../web-app/src/main/resources/project.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_SCHEME:-survey_db}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - survey-network + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - survey-network + + mongodb: + image: mongo:7 + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD} + MONGO_INITDB_DATABASE: ${MONGODB_DATABASE} + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.runCommand('ping').ok", "--quiet"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - survey-network + + rabbitmq: + image: rabbitmq:3.12-management + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-admin} + RABBITMQ_DEFAULT_VHOST: / + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] + interval: 10s + timeout: 5s + retries: 5 + command: > + sh -c " + rabbitmq-plugins enable rabbitmq_delayed_message_exchange && + docker-entrypoint.sh rabbitmq-server + " + networks: + - survey-network + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ports: + - "9200:9200" + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + command: > + sh -c " + elasticsearch-plugin list | grep -q analysis-nori || elasticsearch-plugin install analysis-nori --batch && + /usr/local/bin/docker-entrypoint.sh + " + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - survey-network + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./web-app/src/main/resources/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.path=/prometheus' + - '--web.console.libraries=/etc/prometheus/console_libraries' + - '--web.console.templates=/etc/prometheus/consoles' + - '--web.enable-lifecycle' + networks: + - survey-network + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + depends_on: + - prometheus + networks: + - survey-network + +volumes: + postgres_data: + redis_data: + mongodb_data: + rabbitmq_data: + elasticsearch_data: + prometheus_data: + grafana_data: + +networks: + survey-network: + driver: bridge diff --git a/participation-module/.dockerignore b/participation-module/.dockerignore new file mode 100644 index 000000000..b8f730fe5 --- /dev/null +++ b/participation-module/.dockerignore @@ -0,0 +1,64 @@ +user-module/ +survey-module/ +project-module/ +statistic-module/ +share-module/ +web-app/ + +build/ +target/ +.gradle/ +out/ +bin/ + +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo + +.DS_Store +Thumbs.db +desktop.ini + +.env +.env.* +properties.env +docker/ + +*.md +docs/ +images/ +*.png +*.jpg +*.jpeg +*.gif + +test-results/ +coverage/ +*test.properties +jacoco/ + +logs/ +*.log +*.log.* + +tmp/ +temp/ +*.tmp +*.temp + +grafana/ +prometheus/ +ecs-task-definitions/ +loadtest-package/ + +.git/ +.gitignore +.gitattributes + +.github/ +.gitlab-ci.yml +.travis.yml diff --git a/participation-module/Dockerfile b/participation-module/Dockerfile new file mode 100644 index 000000000..42524e14d --- /dev/null +++ b/participation-module/Dockerfile @@ -0,0 +1,44 @@ +FROM eclipse-temurin:17-jdk AS builder + +WORKDIR /app + +COPY gradle/ gradle/ +COPY gradlew . +COPY build.gradle . +COPY settings.gradle . + +COPY shared-kernel/build.gradle shared-kernel/ +COPY shared-kernel/src/ shared-kernel/src/ + +COPY participation-module/build.gradle participation-module/ +COPY participation-module/src/ participation-module/src/ + +RUN ./gradlew :participation-module:bootJar --no-daemon + +FROM eclipse-temurin:17-jre-alpine AS runtime + +RUN apk add --no-cache curl + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder /app/participation-module/build/libs/*.jar app.jar + +RUN chown appuser:appgroup app.jar + +USER appuser + +EXPOSE 8084 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8084/actuator/health || exit 1 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+ExitOnOutOfMemoryError", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] diff --git a/participation-module/build.gradle b/participation-module/build.gradle new file mode 100644 index 000000000..72bbfb42b --- /dev/null +++ b/participation-module/build.gradle @@ -0,0 +1,20 @@ +jar { + enabled = true + archiveClassifier = '' +} + +bootJar { + enabled = false +} + +dependencies { + implementation project(':shared-kernel') + + runtimeOnly 'org.postgresql:postgresql' + + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + testImplementation 'org.springframework.security:spring-security-test' +} \ No newline at end of file diff --git a/participation-module/docker-compose.yml b/participation-module/docker-compose.yml new file mode 100644 index 000000000..d63b2c214 --- /dev/null +++ b/participation-module/docker-compose.yml @@ -0,0 +1,83 @@ +version: '3.8' + +services: + participation-service: + build: + context: .. + dockerfile: participation-module/Dockerfile + ports: + - "8084:8084" + environment: + - SPRING_PROFILES_ACTIVE=dev + - SERVER_PORT=8084 + - MONGODB_HOST=mongodb + - MONGODB_PORT=${MONGODB_PORT:-27017} + - MONGODB_DATABASE=${MONGODB_DATABASE} + - MONGODB_USERNAME=${MONGODB_USERNAME} + - MONGODB_PASSWORD=${MONGODB_PASSWORD} + - RABBITMQ_HOST=rabbitmq + - RABBITMQ_PORT=${RABBITMQ_PORT:-5672} + - RABBITMQ_USERNAME=${RABBITMQ_USERNAME:-admin} + - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD:-admin} + depends_on: + mongodb: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8084/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - participation-network + + mongodb: + image: mongo:7 + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD} + MONGO_INITDB_DATABASE: ${MONGODB_DATABASE} + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.runCommand('ping').ok", "--quiet"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - participation-network + + rabbitmq: + image: rabbitmq:3.12-management + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-admin} + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] + interval: 10s + timeout: 5s + retries: 5 + command: > + sh -c " + rabbitmq-plugins enable rabbitmq_delayed_message_exchange && + docker-entrypoint.sh rabbitmq-server + " + networks: + - participation-network + +volumes: + mongodb_data: + rabbitmq_data: + +networks: + participation-network: + driver: bridge diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/api/ParticipationController.java b/participation-module/src/main/java/com/example/surveyapi/participation/api/ParticipationController.java new file mode 100644 index 000000000..4e103876b --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/api/ParticipationController.java @@ -0,0 +1,53 @@ +package com.example.surveyapi.participation.api; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.participation.application.ParticipationService; +import com.example.surveyapi.participation.application.dto.request.CreateParticipationRequest; +import com.example.surveyapi.global.dto.ApiResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api") +public class ParticipationController { + + private final ParticipationService participationService; + + @PostMapping("/surveys/{surveyId}/participations") + public ResponseEntity> create( + @RequestHeader("Authorization") String authHeader, + @PathVariable Long surveyId, + @Valid @RequestBody CreateParticipationRequest request, + @AuthenticationPrincipal Long userId + ) { + Long participationId = participationService.create(authHeader, surveyId, userId, request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success("설문 응답 제출이 완료되었습니다.", participationId)); + } + + @PutMapping("/participations/{participationId}") + public ResponseEntity> update( + @RequestHeader("Authorization") String authHeader, + @PathVariable Long participationId, + @Valid @RequestBody CreateParticipationRequest request, + @AuthenticationPrincipal Long userId + ) { + participationService.update(authHeader, userId, participationId, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("참여 응답 수정이 완료되었습니다.", null)); + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/api/ParticipationQueryController.java b/participation-module/src/main/java/com/example/surveyapi/participation/api/ParticipationQueryController.java new file mode 100644 index 000000000..842b82fcb --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/api/ParticipationQueryController.java @@ -0,0 +1,75 @@ +package com.example.surveyapi.participation.api; + +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.participation.application.ParticipationQueryService; +import com.example.surveyapi.participation.application.dto.response.ParticipationDetailResponse; +import com.example.surveyapi.participation.application.dto.response.ParticipationGroupResponse; +import com.example.surveyapi.participation.application.dto.response.ParticipationInfoResponse; +import com.example.surveyapi.global.dto.ApiResponse; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api") +public class ParticipationQueryController { + + private final ParticipationQueryService participationQueryService; + + @GetMapping("/surveys/participations") + public ResponseEntity>> getAllBySurveyIds( + @RequestParam(required = true) List surveyIds + ) { + List result = participationQueryService.getAllBySurveyIds(surveyIds); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("여러 참여 기록 조회에 성공하였습니다.", result)); + } + + @GetMapping("/members/me/participations") + public ResponseEntity>> getAll( + @RequestHeader("Authorization") String authHeader, + @AuthenticationPrincipal Long userId, + Pageable pageable + ) { + Page result = participationQueryService.gets(authHeader, userId, pageable); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("나의 참여 목록 조회에 성공하였습니다.", result)); + } + + @GetMapping("/participations/{participationId}") + public ResponseEntity> get( + @PathVariable Long participationId, + @AuthenticationPrincipal Long userId + ) { + ParticipationDetailResponse result = participationQueryService.get(userId, participationId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("참여 응답 상세 조회에 성공하였습니다.", result)); + } + + @GetMapping("/surveys/participations/count") + public ResponseEntity>> getParticipationCounts( + @RequestParam List surveyIds + ) { + Map counts = participationQueryService.getCountsBySurveyIds(surveyIds); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("참여 count 성공", counts)); + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/ParticipationQueryService.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/ParticipationQueryService.java new file mode 100644 index 000000000..462785e47 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/ParticipationQueryService.java @@ -0,0 +1,110 @@ +package com.example.surveyapi.participation.application; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import com.example.surveyapi.participation.application.client.SurveyInfoDto; +import com.example.surveyapi.participation.application.client.SurveyServicePort; +import com.example.surveyapi.participation.application.dto.response.ParticipationDetailResponse; +import com.example.surveyapi.participation.application.dto.response.ParticipationGroupResponse; +import com.example.surveyapi.participation.application.dto.response.ParticipationInfoResponse; +import com.example.surveyapi.participation.domain.participation.ParticipationRepository; +import com.example.surveyapi.participation.domain.participation.query.ParticipationInfo; +import com.example.surveyapi.participation.domain.participation.query.ParticipationProjection; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +@Service +public class ParticipationQueryService { + + private final ParticipationRepository participationRepository; + private final SurveyServicePort surveyPort; + private final TransactionTemplate readTransactionTemplate; + + public ParticipationQueryService(ParticipationRepository participationRepository, SurveyServicePort surveyPort, + PlatformTransactionManager transactionManager) { + this.participationRepository = participationRepository; + this.surveyPort = surveyPort; + this.readTransactionTemplate = new TransactionTemplate(transactionManager); + this.readTransactionTemplate.setReadOnly(true); + } + + public Page gets(String authHeader, Long userId, Pageable pageable) { + Page participationInfos = readTransactionTemplate.execute(status -> + participationRepository.findParticipationInfos(userId, pageable) + ); + + if (participationInfos == null || participationInfos.isEmpty()) { + return Page.empty(pageable); + } + + List surveyIds = participationInfos.getContent().stream() + .map(ParticipationInfo::getSurveyId) + .distinct() + .toList(); + + List surveyInfoList = surveyPort.getSurveyInfoList(authHeader, surveyIds); + + List surveyInfoOfParticipations = surveyInfoList.stream() + .map(ParticipationInfoResponse.SurveyInfoOfParticipation::from) + .toList(); + + Map surveyInfoMap = surveyInfoOfParticipations.stream() + .collect(Collectors.toMap( + ParticipationInfoResponse.SurveyInfoOfParticipation::getSurveyId, + surveyInfo -> surveyInfo + )); + + return participationInfos.map(p -> { + ParticipationInfoResponse.SurveyInfoOfParticipation surveyInfo = surveyInfoMap.get(p.getSurveyId()); + + return ParticipationInfoResponse.of(p, surveyInfo); + }); + } + + @Transactional(readOnly = true) + public List getAllBySurveyIds(List surveyIds) { + List projections = participationRepository.findParticipationProjectionsBySurveyIds( + surveyIds); + + // surveyId 기준으로 참여 기록을 Map 으로 그룹핑 + Map> participationGroupBySurveyId = projections.stream() + .collect(Collectors.groupingBy(ParticipationProjection::getSurveyId)); + + List result = new ArrayList<>(); + + for (Long surveyId : surveyIds) { + List participationGroup = participationGroupBySurveyId.getOrDefault(surveyId, + Collections.emptyList()); + + List participationDtos = participationGroup.stream() + .map(ParticipationDetailResponse::fromProjection) + .toList(); + + result.add(ParticipationGroupResponse.of(surveyId, participationDtos)); + } + return result; + } + + @Transactional(readOnly = true) + public ParticipationDetailResponse get(Long userId, Long participationId) { + return participationRepository.findParticipationProjectionByIdAndUserId(participationId, userId) + .map(ParticipationDetailResponse::fromProjection) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_PARTICIPATION)); + } + + @Transactional(readOnly = true) + public Map getCountsBySurveyIds(List surveyIds) { + return participationRepository.countsBySurveyIds(surveyIds); + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/ParticipationService.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/ParticipationService.java new file mode 100644 index 000000000..09ee8810b --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/ParticipationService.java @@ -0,0 +1,242 @@ +package com.example.surveyapi.participation.application; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.participation.application.client.SurveyDetailDto; +import com.example.surveyapi.participation.application.client.SurveyServicePort; +import com.example.surveyapi.participation.application.client.UserServicePort; +import com.example.surveyapi.participation.application.client.UserSnapshotDto; +import com.example.surveyapi.participation.application.client.enums.SurveyApiQuestionType; +import com.example.surveyapi.participation.application.client.enums.SurveyApiStatus; +import com.example.surveyapi.participation.application.dto.request.CreateParticipationRequest; +import com.example.surveyapi.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.domain.participation.Participation; +import com.example.surveyapi.participation.domain.participation.ParticipationRepository; +import com.example.surveyapi.participation.domain.participation.vo.ParticipantInfo; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class ParticipationService { + + private final ParticipationRepository participationRepository; + private final SurveyServicePort surveyPort; + private final UserServicePort userPort; + private final TaskExecutor taskExecutor; + + public ParticipationService(ParticipationRepository participationRepository, + SurveyServicePort surveyPort, + UserServicePort userPort, + @Qualifier("externalAPI") TaskExecutor taskExecutor + ) { + this.participationRepository = participationRepository; + this.surveyPort = surveyPort; + this.userPort = userPort; + this.taskExecutor = taskExecutor; + } + + @Transactional + public Long create(String authHeader, Long surveyId, Long userId, CreateParticipationRequest request) { + log.debug("설문 참여 생성 시작. surveyId: {}, userId: {}", surveyId, userId); + long totalStartTime = System.currentTimeMillis(); + + validateParticipationDuplicated(surveyId, userId); + + List responseDataList = request.getResponseDataList(); + + CompletableFuture futureSurveyDetail = CompletableFuture.supplyAsync( + () -> surveyPort.getSurveyDetail(authHeader, surveyId), taskExecutor).orTimeout(3, TimeUnit.SECONDS); + + CompletableFuture futureUserSnapshot = CompletableFuture.supplyAsync( + () -> userPort.getParticipantInfo(authHeader, userId), taskExecutor).orTimeout(3, TimeUnit.SECONDS); + + final SurveyDetailDto surveyDetail; + final UserSnapshotDto userSnapshot; + + try { + surveyDetail = futureSurveyDetail.get(); + userSnapshot = futureUserSnapshot.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("비동기 호출 중 인터럽트 발생", e); + futureSurveyDetail.cancel(true); + futureUserSnapshot.cancel(true); + throw new CustomException(CustomErrorCode.EXTERNAL_API_ERROR); + } catch (ExecutionException e) { + log.error("비동기 호출 실패", e); + futureSurveyDetail.cancel(true); + futureUserSnapshot.cancel(true); + throw new CustomException(CustomErrorCode.EXTERNAL_API_ERROR); + } + + validateSurveyActive(surveyDetail); + validateResponses(responseDataList, surveyDetail.getQuestions()); + + ParticipantInfo participantInfo = ParticipantInfo.of(userSnapshot.getBirth(), userSnapshot.getGender(), + userSnapshot.getRegion()); + + Participation participation = Participation.create(userId, surveyId, participantInfo, responseDataList); + Participation savedParticipation = participationRepository.save(participation); + + savedParticipation.registerCreatedEvent(); + + participationRepository.save(savedParticipation); + + long totalEndTime = System.currentTimeMillis(); + log.debug("설문 참여 생성 완료. 총 처리 시간: {}ms", (totalEndTime - totalStartTime)); + + return savedParticipation.getId(); + } + + @Transactional + public void update(String authHeader, Long userId, Long participationId, + CreateParticipationRequest request) { + log.debug("설문 참여 수정 시작. participationId: {}, userId: {}", participationId, userId); + long totalStartTime = System.currentTimeMillis(); + + List responseDataList = request.getResponseDataList(); + Participation participation = getParticipationOrThrow(participationId); + + participation.validateOwner(userId); + + SurveyDetailDto surveyDetail = surveyPort.getSurveyDetail(authHeader, participation.getSurveyId()); + + validateSurveyActive(surveyDetail); + validateAllowUpdate(surveyDetail); + validateResponses(responseDataList, surveyDetail.getQuestions()); + participation.update(responseDataList); + + long totalEndTime = System.currentTimeMillis(); + log.debug("설문 참여 수정 완료. 총 처리 시간: {}ms", (totalEndTime - totalStartTime)); + } + + /* + private 메소드 정의 + */ + private void validateParticipationDuplicated(Long surveyId, Long userId) { + if (participationRepository.exists(surveyId, userId)) { + throw new CustomException(CustomErrorCode.SURVEY_ALREADY_PARTICIPATED); + } + } + + private void validateSurveyActive(SurveyDetailDto surveyDetail) { + if (!(surveyDetail.getStatus().equals(SurveyApiStatus.IN_PROGRESS) + && surveyDetail.getDuration().getEndDate().isAfter(LocalDateTime.now()))) { + + throw new CustomException(CustomErrorCode.SURVEY_NOT_ACTIVE); + } + } + + private Participation getParticipationOrThrow(Long participationId) { + return participationRepository.findById(participationId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_PARTICIPATION)); + } + + private void validateAllowUpdate(SurveyDetailDto surveyDetail) { + if (!surveyDetail.getOption().isAllowResponseUpdate()) { + throw new CustomException(CustomErrorCode.CANNOT_UPDATE_RESPONSE); + } + } + + private void validateResponses( + List responses, + List questions + ) { + Map responseMap = responses.stream() + .collect(Collectors.toMap(ResponseData::getQuestionId, r -> r)); + + // 응답한 questionIds와 설문의 questionIds가 일치하는지 검증, answer = null 이여도 questionId는 존재해야 한다. + if (responseMap.size() != questions.size() || !responseMap.keySet().equals( + questions.stream() + .map(SurveyDetailDto.QuestionValidationInfo::getQuestionId) + .collect(Collectors.toSet()) + )) { + throw new CustomException(CustomErrorCode.INVALID_SURVEY_QUESTION); + } + + for (SurveyDetailDto.QuestionValidationInfo question : questions) { + ResponseData response = responseMap.get(question.getQuestionId()); + Map answer = response.getAnswer(); + SurveyApiQuestionType questionType = question.getQuestionType(); + + switch (questionType) { + case SINGLE_CHOICE: { + if (!answer.containsKey("choice") || !(answer.get("choice") instanceof List choiceList) + || choiceList.size() > 1) { + log.error("INVALID_ANSWER_TYPE ERROR: not choice, questionId = {}", question.getQuestionId()); + throw new CustomException(CustomErrorCode.INVALID_ANSWER_TYPE); + } + + if (choiceList.isEmpty() && question.getIsRequired()) { + log.error("REQUIRED_QUESTION_NOT_ANSWERED ERROR: questionId = {}", + question.getQuestionId()); + throw new CustomException(CustomErrorCode.REQUIRED_QUESTION_NOT_ANSWERED); + } + Set validateChoiceIds = question.getChoices().stream() + .map(SurveyDetailDto.ChoiceNumber::getChoiceId).collect(Collectors.toSet()); + + for (Object choice : choiceList) { + if (!(choice instanceof Integer choiceId) || !validateChoiceIds.contains(choiceId)) { + log.error("INVALID_CHOICE_ID ERROR: questionId = {}, choiceId = {}", + question.getQuestionId(), choice instanceof Integer choiceId); + throw new CustomException(CustomErrorCode.INVALID_CHOICE_ID); + } + } + break; + } + case MULTIPLE_CHOICE: { + if (!answer.containsKey("choices") || + !(answer.get("choices") instanceof List choiceList)) { + log.error("INVALID_ANSWER_TYPE ERROR: not choices, questionId = {}", question.getQuestionId()); + throw new CustomException(CustomErrorCode.INVALID_ANSWER_TYPE); + } + + if (choiceList.isEmpty() && question.getIsRequired()) { + log.error("REQUIRED_QUESTION_NOT_ANSWERED ERROR: questionId = {}", + question.getQuestionId()); + throw new CustomException(CustomErrorCode.REQUIRED_QUESTION_NOT_ANSWERED); + } + Set validateChoiceIds = question.getChoices().stream() + .map(SurveyDetailDto.ChoiceNumber::getChoiceId).collect(Collectors.toSet()); + + for (Object choice : choiceList) { + if (!(choice instanceof Integer choiceId) || !validateChoiceIds.contains(choiceId)) { + log.error("INVALID_CHOICE_ID ERROR: questionId = {}, choiceId = {}", + question.getQuestionId(), choice instanceof Integer choiceId); + throw new CustomException(CustomErrorCode.INVALID_CHOICE_ID); + } + } + break; + } + case SHORT_ANSWER, LONG_ANSWER: { + if (!answer.containsKey("textAnswer") || !(answer.get("textAnswer") instanceof String textAnswer)) { + log.error("INVALID_ANSWER_TYPE ERROR: not textAnswer, questionId = {}", + question.getQuestionId()); + throw new CustomException(CustomErrorCode.INVALID_ANSWER_TYPE); + } + if (textAnswer.isBlank() && question.getIsRequired()) { + log.error("REQUIRED_QUESTION_NOT_ANSWERED ERROR: questionId = {}", + question.getQuestionId()); + throw new CustomException(CustomErrorCode.REQUIRED_QUESTION_NOT_ANSWERED); + } + break; + } + } + } + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyDetailDto.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyDetailDto.java new file mode 100644 index 000000000..f24190195 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyDetailDto.java @@ -0,0 +1,43 @@ +package com.example.surveyapi.participation.application.client; + +import java.io.Serializable; +import java.time.LocalDateTime; +import java.util.List; + +import com.example.surveyapi.participation.application.client.enums.SurveyApiQuestionType; +import com.example.surveyapi.participation.application.client.enums.SurveyApiStatus; + +import lombok.Getter; + +@Getter +public class SurveyDetailDto implements Serializable { + + private Long surveyId; + private SurveyApiStatus status; + private Duration duration; + private Option option; + private List questions; + + @Getter + public static class Duration implements Serializable { + private LocalDateTime endDate; + } + + @Getter + public static class Option implements Serializable { + private boolean allowResponseUpdate; + } + + @Getter + public static class QuestionValidationInfo implements Serializable { + private Long questionId; + private Boolean isRequired; + private SurveyApiQuestionType questionType; + private List choices; + } + + @Getter + public static class ChoiceNumber implements Serializable { + private Integer choiceId; + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyInfoDto.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyInfoDto.java new file mode 100644 index 000000000..fb64e6998 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyInfoDto.java @@ -0,0 +1,28 @@ +package com.example.surveyapi.participation.application.client; + +import java.io.Serializable; +import java.time.LocalDateTime; + +import com.example.surveyapi.participation.application.client.enums.SurveyApiStatus; + +import lombok.Getter; + +@Getter +public class SurveyInfoDto implements Serializable { + + private Long surveyId; + private String title; + private SurveyApiStatus status; + private Option option; + private Duration duration; + + @Getter + public static class Duration implements Serializable { + private LocalDateTime endDate; + } + + @Getter + public static class Option implements Serializable { + private boolean allowResponseUpdate; + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyServicePort.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyServicePort.java new file mode 100644 index 000000000..c683dcdc4 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/SurveyServicePort.java @@ -0,0 +1,9 @@ +package com.example.surveyapi.participation.application.client; + +import java.util.List; + +public interface SurveyServicePort { + SurveyDetailDto getSurveyDetail(String authHeader, Long surveyId); + + List getSurveyInfoList(String authHeader, List surveyIds); +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/client/UserServicePort.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/UserServicePort.java new file mode 100644 index 000000000..634c772a2 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/UserServicePort.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.participation.application.client; + +public interface UserServicePort { + UserSnapshotDto getParticipantInfo(String authHeader, Long userId); +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/client/UserSnapshotDto.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/UserSnapshotDto.java new file mode 100644 index 000000000..973347f29 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/UserSnapshotDto.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.participation.application.client; + +import com.example.surveyapi.participation.domain.participation.enums.Gender; +import com.example.surveyapi.participation.domain.participation.vo.Region; + +import lombok.Getter; + +@Getter +public class UserSnapshotDto { + private String birth; + private Gender gender; + private Region region; +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/client/enums/SurveyApiQuestionType.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/enums/SurveyApiQuestionType.java new file mode 100644 index 000000000..383cc31e4 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/enums/SurveyApiQuestionType.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.participation.application.client.enums; + +public enum SurveyApiQuestionType { + SINGLE_CHOICE, + MULTIPLE_CHOICE, + SHORT_ANSWER, + LONG_ANSWER +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/client/enums/SurveyApiStatus.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/enums/SurveyApiStatus.java new file mode 100644 index 000000000..974bf8425 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/client/enums/SurveyApiStatus.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.participation.application.client.enums; + +public enum SurveyApiStatus { + PREPARING, IN_PROGRESS, CLOSED, DELETED +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/request/CreateParticipationRequest.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/request/CreateParticipationRequest.java new file mode 100644 index 000000000..50648f326 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/request/CreateParticipationRequest.java @@ -0,0 +1,16 @@ +package com.example.surveyapi.participation.application.dto.request; + +import java.util.List; + +import com.example.surveyapi.participation.domain.command.ResponseData; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; + +@Getter +public class CreateParticipationRequest { + + @NotEmpty(message = "응답 데이터는 최소 1개 이상이어야 합니다.") + private List responseDataList; +} + diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationDetailResponse.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationDetailResponse.java new file mode 100644 index 000000000..63c0f15f3 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationDetailResponse.java @@ -0,0 +1,51 @@ +package com.example.surveyapi.participation.application.dto.response; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import com.example.surveyapi.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.domain.participation.query.ParticipationProjection; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ParticipationDetailResponse { + + private Long participationId; + private LocalDateTime participatedAt; + private List responses; + + public static ParticipationDetailResponse fromProjection(ParticipationProjection projection) { + List responses = projection.getResponses() + .stream() + .map(AnswerDetail::from) + .toList(); + + ParticipationDetailResponse participationDetail = new ParticipationDetailResponse(); + participationDetail.participationId = projection.getParticipationId(); + participationDetail.participatedAt = projection.getParticipatedAt(); + participationDetail.responses = responses; + + return participationDetail; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class AnswerDetail { + + private Long questionId; + private Map answer; + + public static AnswerDetail from(ResponseData response) { + AnswerDetail answerDetail = new AnswerDetail(); + answerDetail.questionId = response.getQuestionId(); + answerDetail.answer = response.getAnswer(); + + return answerDetail; + } + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationGroupResponse.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationGroupResponse.java new file mode 100644 index 000000000..7318d4d61 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationGroupResponse.java @@ -0,0 +1,23 @@ +package com.example.surveyapi.participation.application.dto.response; + +import java.util.List; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ParticipationGroupResponse { + + private Long surveyId; + private List participations; + + public static ParticipationGroupResponse of(Long surveyId, List participations) { + ParticipationGroupResponse participationGroup = new ParticipationGroupResponse(); + participationGroup.surveyId = surveyId; + participationGroup.participations = participations; + + return participationGroup; + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationInfoResponse.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationInfoResponse.java new file mode 100644 index 000000000..7d060615d --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/dto/response/ParticipationInfoResponse.java @@ -0,0 +1,53 @@ +package com.example.surveyapi.participation.application.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.example.surveyapi.participation.application.client.SurveyInfoDto; +import com.example.surveyapi.participation.application.client.enums.SurveyApiStatus; +import com.example.surveyapi.participation.domain.participation.query.ParticipationInfo; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ParticipationInfoResponse { + + private Long participationId; + private SurveyInfoOfParticipation surveyInfo; + private LocalDateTime participatedAt; + + public static ParticipationInfoResponse of(ParticipationInfo participationInfo, + SurveyInfoOfParticipation surveyInfo) { + ParticipationInfoResponse participationInfoResponse = new ParticipationInfoResponse(); + participationInfoResponse.participationId = participationInfo.getParticipationId(); + participationInfoResponse.participatedAt = participationInfo.getParticipatedAt(); + participationInfoResponse.surveyInfo = surveyInfo; + + return participationInfoResponse; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class SurveyInfoOfParticipation { + + private Long surveyId; + private String title; + private SurveyApiStatus status; + private LocalDate endDate; + private boolean allowResponseUpdate; + + public static SurveyInfoOfParticipation from(SurveyInfoDto surveyInfoDto) { + SurveyInfoOfParticipation surveyInfo = new SurveyInfoOfParticipation(); + surveyInfo.surveyId = surveyInfoDto.getSurveyId(); + surveyInfo.title = surveyInfoDto.getTitle(); + surveyInfo.status = surveyInfoDto.getStatus(); + surveyInfo.endDate = surveyInfoDto.getDuration().getEndDate().toLocalDate(); + surveyInfo.allowResponseUpdate = surveyInfoDto.getOption().isAllowResponseUpdate(); + + return surveyInfo; + } + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/event/ParticipationEventListener.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/event/ParticipationEventListener.java new file mode 100644 index 000000000..5040fa874 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/event/ParticipationEventListener.java @@ -0,0 +1,41 @@ +package com.example.surveyapi.participation.application.event; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.example.surveyapi.participation.domain.event.ParticipationCreatedEvent; +import com.example.surveyapi.participation.domain.event.ParticipationEvent; +import com.example.surveyapi.participation.domain.event.ParticipationUpdatedEvent; +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.participation.ParticipationCreatedGlobalEvent; +import com.example.surveyapi.global.event.participation.ParticipationUpdatedGlobalEvent; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ParticipationEventListener { + + private final ParticipationEventPublisherPort rabbitPublisher; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ParticipationEvent event) { + if (event instanceof ParticipationCreatedEvent) { + ParticipationCreatedGlobalEvent createdGlobalEvent = objectMapper.convertValue(event, + new TypeReference() { + }); + + rabbitPublisher.publish(createdGlobalEvent, EventCode.PARTICIPATION_CREATED); + } else if (event instanceof ParticipationUpdatedEvent) { + ParticipationUpdatedGlobalEvent updatedGlobalEvent = objectMapper.convertValue(event, + new TypeReference() { + }); + + rabbitPublisher.publish(updatedGlobalEvent, EventCode.PARTICIPATION_UPDATED); + } + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/application/event/ParticipationEventPublisherPort.java b/participation-module/src/main/java/com/example/surveyapi/participation/application/event/ParticipationEventPublisherPort.java new file mode 100644 index 000000000..7c4c2d9b4 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/application/event/ParticipationEventPublisherPort.java @@ -0,0 +1,9 @@ +package com.example.surveyapi.participation.application.event; + +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.participation.ParticipationGlobalEvent; + +public interface ParticipationEventPublisherPort { + + void publish(ParticipationGlobalEvent event, EventCode key); +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/command/ResponseData.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/command/ResponseData.java new file mode 100644 index 000000000..28c1758ef --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/command/ResponseData.java @@ -0,0 +1,14 @@ +package com.example.surveyapi.participation.domain.command; + +import java.util.Map; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class ResponseData { + + @NotNull + private Long questionId; + private Map answer; +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationCreatedEvent.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationCreatedEvent.java new file mode 100644 index 000000000..55622f665 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationCreatedEvent.java @@ -0,0 +1,72 @@ +package com.example.surveyapi.participation.domain.event; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.example.surveyapi.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.domain.participation.Participation; +import com.example.surveyapi.participation.domain.participation.vo.ParticipantInfo; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ParticipationCreatedEvent implements ParticipationEvent { + + private Long participationId; + private Long surveyId; + private Long userId; + private ParticipantInfo demographic; + private LocalDateTime completedAt; + private List answers; + + public static ParticipationCreatedEvent from(Participation participation) { + ParticipationCreatedEvent createdEvent = new ParticipationCreatedEvent(); + createdEvent.participationId = participation.getId(); + createdEvent.surveyId = participation.getSurveyId(); + createdEvent.userId = participation.getUserId(); + createdEvent.demographic = participation.getParticipantInfo(); + createdEvent.completedAt = participation.getUpdatedAt(); + createdEvent.answers = Answer.from(participation.getAnswers()); + + return createdEvent; + } + + @Getter + private static class Answer { + + private Long questionId; + private List choiceIds = new ArrayList<>(); + private String responseText; + + private static List from(List responses) { + return responses.stream() + .map(response -> { + Answer answerDto = new Answer(); + answerDto.questionId = response.getQuestionId(); + + Map rawAnswer = response.getAnswer(); + + if (rawAnswer != null && !rawAnswer.isEmpty()) { + Object value = rawAnswer.values().iterator().next(); + + if (value instanceof String) { + answerDto.responseText = (String)value; + } else if (value instanceof List rawList) { + answerDto.choiceIds = rawList.stream() + .filter(Integer.class::isInstance) + .map(Integer.class::cast) + .collect(Collectors.toList()); + } + } + return answerDto; + }) + .collect(Collectors.toList()); + } + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationEvent.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationEvent.java new file mode 100644 index 000000000..9eca44666 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationEvent.java @@ -0,0 +1,4 @@ +package com.example.surveyapi.participation.domain.event; + +public interface ParticipationEvent { +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationUpdatedEvent.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationUpdatedEvent.java new file mode 100644 index 000000000..ef17fbde5 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/event/ParticipationUpdatedEvent.java @@ -0,0 +1,69 @@ +package com.example.surveyapi.participation.domain.event; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import com.example.surveyapi.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.domain.participation.Participation; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ParticipationUpdatedEvent implements ParticipationEvent { + + private Long participationId; + private Long surveyId; + private Long userId; + private LocalDateTime completedAt; + private List answers; + + public static ParticipationUpdatedEvent from(Participation participation) { + ParticipationUpdatedEvent updatedEvent = new ParticipationUpdatedEvent(); + updatedEvent.participationId = participation.getId(); + updatedEvent.surveyId = participation.getSurveyId(); + updatedEvent.userId = participation.getUserId(); + updatedEvent.completedAt = participation.getUpdatedAt(); + updatedEvent.answers = Answer.from(participation.getAnswers()); + + return updatedEvent; + } + + @Getter + private static class Answer { + + private Long questionId; + private List choiceIds = new ArrayList<>(); + private String responseText; + + private static List from(List responses) { + return responses.stream() + .map(response -> { + Answer answerDto = new Answer(); + answerDto.questionId = response.getQuestionId(); + + Map rawAnswer = response.getAnswer(); + + if (rawAnswer != null && !rawAnswer.isEmpty()) { + Object value = rawAnswer.values().iterator().next(); + + if (value instanceof String) { + answerDto.responseText = (String)value; + } else if (value instanceof List rawList) { + answerDto.choiceIds = rawList.stream() + .filter(Integer.class::isInstance) + .map(Integer.class::cast) + .collect(Collectors.toList()); + } + } + return answerDto; + }) + .collect(Collectors.toList()); + } + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/Participation.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/Participation.java new file mode 100644 index 000000000..c39bc250a --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/Participation.java @@ -0,0 +1,75 @@ +package com.example.surveyapi.participation.domain.participation; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.example.surveyapi.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.domain.event.ParticipationCreatedEvent; +import com.example.surveyapi.participation.domain.event.ParticipationUpdatedEvent; +import com.example.surveyapi.participation.domain.participation.vo.ParticipantInfo; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; +import com.example.surveyapi.global.model.AbstractRoot; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "participations") +public class Participation extends AbstractRoot { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long userId; + + @Column(nullable = false) + private Long surveyId; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", nullable = false) + private ParticipantInfo participantInfo; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb", nullable = false) + private List answers = new ArrayList<>(); + + public static Participation create(Long userId, Long surveyId, ParticipantInfo participantInfo, + List responseDataList) { + Participation participation = new Participation(); + participation.userId = userId; + participation.surveyId = surveyId; + participation.participantInfo = participantInfo; + participation.answers = responseDataList; + + return participation; + } + + public void registerCreatedEvent() { + registerEvent(ParticipationCreatedEvent.from(this)); + } + + public void validateOwner(Long userId) { + if (!this.userId.equals(userId)) { + throw new CustomException(CustomErrorCode.ACCESS_DENIED_PARTICIPATION_VIEW); + } + } + + public void update(List responseDataList) { + this.answers = responseDataList; + registerEvent(ParticipationUpdatedEvent.from(this)); + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/ParticipationRepository.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/ParticipationRepository.java new file mode 100644 index 000000000..c647d8dcd --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/ParticipationRepository.java @@ -0,0 +1,27 @@ +package com.example.surveyapi.participation.domain.participation; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.example.surveyapi.participation.domain.participation.query.ParticipationInfo; +import com.example.surveyapi.participation.domain.participation.query.ParticipationProjection; + +public interface ParticipationRepository { + Participation save(Participation participation); + + Optional findById(Long participationId); + + boolean exists(Long surveyId, Long userId); + + Page findParticipationInfos(Long userId, Pageable pageable); + + Map countsBySurveyIds(List surveyIds); + + List findParticipationProjectionsBySurveyIds(List surveyIds); + + Optional findParticipationProjectionByIdAndUserId(Long participationId, Long loginUserId); +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/enums/Gender.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/enums/Gender.java new file mode 100644 index 000000000..171c64235 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/enums/Gender.java @@ -0,0 +1,6 @@ +package com.example.surveyapi.participation.domain.participation.enums; + +public enum Gender { + MALE, + FEMALE +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/ParticipationInfo.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/ParticipationInfo.java new file mode 100644 index 000000000..4aa17fee8 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/ParticipationInfo.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.participation.domain.participation.query; + +import java.time.LocalDateTime; + +import lombok.Getter; + +@Getter +public class ParticipationInfo { + + private Long participationId; + private Long surveyId; + private LocalDateTime participatedAt; + + public ParticipationInfo(Long participationId, Long surveyId, LocalDateTime participatedAt) { + this.participationId = participationId; + this.surveyId = surveyId; + this.participatedAt = participatedAt; + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/ParticipationProjection.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/ParticipationProjection.java new file mode 100644 index 000000000..7b59126de --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/ParticipationProjection.java @@ -0,0 +1,25 @@ +package com.example.surveyapi.participation.domain.participation.query; + +import java.time.LocalDateTime; +import java.util.List; + +import com.example.surveyapi.participation.domain.command.ResponseData; + +import lombok.Getter; + +@Getter +public class ParticipationProjection { + + private final Long surveyId; + private final Long participationId; + private final LocalDateTime participatedAt; + private final List responses; + + public ParticipationProjection(Long surveyId, Long participationId, LocalDateTime participatedAt, + List responses) { + this.surveyId = surveyId; + this.participationId = participationId; + this.participatedAt = participatedAt; + this.responses = responses; + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/QuestionAnswer.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/QuestionAnswer.java new file mode 100644 index 000000000..e3c13b0c4 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/query/QuestionAnswer.java @@ -0,0 +1,17 @@ +package com.example.surveyapi.participation.domain.participation.query; + +import java.util.Map; + +import lombok.Getter; + +@Getter +public class QuestionAnswer { + + private Long questionId; + private Map answer; + + public QuestionAnswer(Long questionId, Map answer) { + this.questionId = questionId; + this.answer = answer; + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/vo/ParticipantInfo.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/vo/ParticipantInfo.java new file mode 100644 index 000000000..8deb3ca5d --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/vo/ParticipantInfo.java @@ -0,0 +1,35 @@ +package com.example.surveyapi.participation.domain.participation.vo; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import com.example.surveyapi.participation.domain.participation.enums.Gender; + +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@EqualsAndHashCode +public class ParticipantInfo { + + private LocalDate birth; + + @Enumerated(EnumType.STRING) + private Gender gender; + + private Region region; + + public static ParticipantInfo of(String birth, Gender gender, Region region) { + ParticipantInfo participantInfo = new ParticipantInfo(); + participantInfo.birth = LocalDateTime.parse(birth).toLocalDate(); + participantInfo.gender = gender; + participantInfo.region = region; + + return participantInfo; + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/vo/Region.java b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/vo/Region.java new file mode 100644 index 000000000..3d38e28d9 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/domain/participation/vo/Region.java @@ -0,0 +1,24 @@ +package com.example.surveyapi.participation.domain.participation.vo; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class Region { + private String province; + private String district; + + public static Region of(String province, String district) { + Region region = new Region(); + region.province = province; + region.district = district; + + return region; + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/infra/ParticipationRepositoryImpl.java b/participation-module/src/main/java/com/example/surveyapi/participation/infra/ParticipationRepositoryImpl.java new file mode 100644 index 000000000..316481a4b --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/infra/ParticipationRepositoryImpl.java @@ -0,0 +1,63 @@ +package com.example.surveyapi.participation.infra; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.participation.domain.participation.Participation; +import com.example.surveyapi.participation.domain.participation.ParticipationRepository; +import com.example.surveyapi.participation.domain.participation.query.ParticipationInfo; +import com.example.surveyapi.participation.domain.participation.query.ParticipationProjection; +import com.example.surveyapi.participation.infra.dsl.ParticipationQueryDslRepository; +import com.example.surveyapi.participation.infra.jpa.JpaParticipationRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ParticipationRepositoryImpl implements ParticipationRepository { + + private final JpaParticipationRepository jpaParticipationRepository; + private final ParticipationQueryDslRepository participationQueryDslRepository; + + @Override + public Participation save(Participation participation) { + return jpaParticipationRepository.save(participation); + } + + @Override + public Optional findById(Long participationId) { + return jpaParticipationRepository.findByIdAndIsDeletedFalse(participationId); + } + + @Override + public boolean exists(Long surveyId, Long userId) { + return jpaParticipationRepository.existsBySurveyIdAndUserIdAndIsDeletedFalse(surveyId, userId); + } + + @Override + public Page findParticipationInfos(Long userId, Pageable pageable) { + return participationQueryDslRepository.findParticipationInfos(userId, pageable); + } + + @Override + public Map countsBySurveyIds(List surveyIds) { + return participationQueryDslRepository.countsBySurveyIds(surveyIds); + } + + @Override + public List findParticipationProjectionsBySurveyIds(List surveyIds) { + return participationQueryDslRepository.findParticipationProjectionsBySurveyIds(surveyIds); + } + + @Override + public Optional findParticipationProjectionByIdAndUserId(Long participationId, + Long loginUserId) { + return participationQueryDslRepository.findParticipationProjectionByIdAndUserId(participationId, loginUserId); + } + +} \ No newline at end of file diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/infra/adapter/SurveyServiceAdapter.java b/participation-module/src/main/java/com/example/surveyapi/participation/infra/adapter/SurveyServiceAdapter.java new file mode 100644 index 000000000..4e8760cc7 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/infra/adapter/SurveyServiceAdapter.java @@ -0,0 +1,73 @@ +package com.example.surveyapi.participation.infra.adapter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.participation.application.client.SurveyDetailDto; +import com.example.surveyapi.participation.application.client.SurveyInfoDto; +import com.example.surveyapi.participation.application.client.SurveyServicePort; +import com.example.surveyapi.global.client.SurveyApiClient; +import com.example.surveyapi.global.dto.ExternalApiResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class SurveyServiceAdapter implements SurveyServicePort { + + private final CacheManager cacheManager; + private final SurveyApiClient surveyApiClient; + private final ObjectMapper objectMapper; + + @Cacheable(value = "surveyDetails", key = "#surveyId", sync = true) + @Override + public SurveyDetailDto getSurveyDetail(String authHeader, Long surveyId) { + ExternalApiResponse surveyDetail = surveyApiClient.getSurveyDetail(authHeader, surveyId); + Object rawData = surveyDetail.getOrThrow(); + + return objectMapper.convertValue(rawData, new TypeReference() { + }); + } + + @Override + public List getSurveyInfoList(String authHeader, List surveyIds) { + Cache surveyInfoCache = Objects.requireNonNull(cacheManager.getCache("surveyInfo")); + + List result = new ArrayList<>(); + List missedIds = new ArrayList<>(); + + for (Long id : surveyIds) { + SurveyInfoDto cachedInfo = surveyInfoCache.get(id, SurveyInfoDto.class); + if (cachedInfo != null) { + result.add(cachedInfo); + } else { + missedIds.add(id); + } + } + + if (!missedIds.isEmpty()) { + ExternalApiResponse surveyInfoList = surveyApiClient.getSurveyInfoList(authHeader, missedIds); + Object rawData = surveyInfoList.getOrThrow(); + + List requireInfoList = objectMapper.convertValue(rawData, + new TypeReference>() { + }); + + requireInfoList.forEach(surveyInfo -> { + surveyInfoCache.put(surveyInfo.getSurveyId(), surveyInfo); + result.add(surveyInfo); + }); + } + + return result; + } +} + diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/infra/adapter/UserServiceAdapter.java b/participation-module/src/main/java/com/example/surveyapi/participation/infra/adapter/UserServiceAdapter.java new file mode 100644 index 000000000..5a884f685 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/infra/adapter/UserServiceAdapter.java @@ -0,0 +1,29 @@ +package com.example.surveyapi.participation.infra.adapter; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.participation.application.client.UserServicePort; +import com.example.surveyapi.participation.application.client.UserSnapshotDto; +import com.example.surveyapi.global.client.UserApiClient; +import com.example.surveyapi.global.dto.ExternalApiResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class UserServiceAdapter implements UserServicePort { + + private final UserApiClient userApiClient; + private final ObjectMapper objectMapper; + + @Override + public UserSnapshotDto getParticipantInfo(String authHeader, Long userId) { + ExternalApiResponse userSnapshot = userApiClient.getParticipantInfo(authHeader, userId); + Object rawData = userSnapshot.getOrThrow(); + + return objectMapper.convertValue(rawData, new TypeReference() { + }); + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/infra/dsl/ParticipationQueryDslRepository.java b/participation-module/src/main/java/com/example/surveyapi/participation/infra/dsl/ParticipationQueryDslRepository.java new file mode 100644 index 000000000..8162766e2 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/infra/dsl/ParticipationQueryDslRepository.java @@ -0,0 +1,101 @@ +package com.example.surveyapi.participation.infra.dsl; + +import static com.example.surveyapi.participation.domain.participation.QParticipation.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.participation.domain.participation.query.ParticipationInfo; +import com.example.surveyapi.participation.domain.participation.query.ParticipationProjection; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ParticipationQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + public Page findParticipationInfos(Long userId, Pageable pageable) { + List participations = queryFactory + .select(Projections.constructor( + ParticipationInfo.class, + participation.id, + participation.surveyId, + participation.updatedAt + )) + .from(participation) + .where(participation.userId.eq(userId)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory + .select(participation.id.count()) + .from(participation) + .where(participation.userId.eq(userId)) + .fetchOne(); + + return new PageImpl<>(participations, pageable, total); + } + + public Map countsBySurveyIds(List surveyIds) { + Map map = queryFactory + .select(participation.surveyId, participation.id.count()) + .from(participation) + .where(participation.surveyId.in(surveyIds)) + .groupBy(participation.surveyId) + .fetch() + .stream() + .collect(Collectors.toMap( + t -> t.get(participation.surveyId), + t -> t.get(participation.id.count()))); + + for (Long surveyId : surveyIds) { + map.putIfAbsent(surveyId, 0L); + } + + return map; + } + + public List findParticipationProjectionsBySurveyIds(List surveyIds) { + return queryFactory + .select(Projections.constructor( + ParticipationProjection.class, + participation.surveyId, + participation.id, + participation.updatedAt, + participation.answers + )) + .from(participation) + .where(participation.surveyId.in(surveyIds)) + .fetch(); + } + + public Optional findParticipationProjectionByIdAndUserId(Long participationId, Long userId) { + ParticipationProjection projection = queryFactory + .select(Projections.constructor( + ParticipationProjection.class, + participation.surveyId, + participation.id, + participation.updatedAt, + participation.answers + )) + .from(participation) + .where( + participation.id.eq(participationId), + participation.userId.eq(userId) + ) + .fetchOne(); + return Optional.ofNullable(projection); + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/infra/event/ParticipationEventPublisher.java b/participation-module/src/main/java/com/example/surveyapi/participation/infra/event/ParticipationEventPublisher.java new file mode 100644 index 000000000..e55ca03c8 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/infra/event/ParticipationEventPublisher.java @@ -0,0 +1,29 @@ +package com.example.surveyapi.participation.infra.event; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Service; + +import com.example.surveyapi.participation.application.event.ParticipationEventPublisherPort; +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.participation.ParticipationGlobalEvent; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ParticipationEventPublisher implements ParticipationEventPublisherPort { + + private final RabbitTemplate rabbitTemplate; + + @Override + public void publish(ParticipationGlobalEvent event, EventCode key) { + if (key.equals(EventCode.PARTICIPATION_CREATED)) { + rabbitTemplate.convertAndSend(RabbitConst.EXCHANGE_NAME, RabbitConst.ROUTING_KEY_PARTICIPATION_CREATE, + event); + } else if (key.equals(EventCode.PARTICIPATION_UPDATED)) { + rabbitTemplate.convertAndSend(RabbitConst.EXCHANGE_NAME, RabbitConst.ROUTING_KEY_PARTICIPATION_UPDATE, + event); + } + } +} diff --git a/participation-module/src/main/java/com/example/surveyapi/participation/infra/jpa/JpaParticipationRepository.java b/participation-module/src/main/java/com/example/surveyapi/participation/infra/jpa/JpaParticipationRepository.java new file mode 100644 index 000000000..15c4b0d00 --- /dev/null +++ b/participation-module/src/main/java/com/example/surveyapi/participation/infra/jpa/JpaParticipationRepository.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.participation.infra.jpa; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.surveyapi.participation.domain.participation.Participation; + +public interface JpaParticipationRepository extends JpaRepository { + Optional findByIdAndIsDeletedFalse(Long id); + + boolean existsBySurveyIdAndUserIdAndIsDeletedFalse(Long surveyId, Long userId); +} diff --git a/src/test/java/com/example/surveyapi/domain/participation/api/ParticipationControllerTest.java b/participation-module/src/test/java/com/example/surveyapi/participation/api/ParticipationControllerTest.java similarity index 96% rename from src/test/java/com/example/surveyapi/domain/participation/api/ParticipationControllerTest.java rename to participation-module/src/test/java/com/example/surveyapi/participation/api/ParticipationControllerTest.java index 59a281f4d..7baf5d855 100644 --- a/src/test/java/com/example/surveyapi/domain/participation/api/ParticipationControllerTest.java +++ b/participation-module/src/test/java/com/example/surveyapi/participation/api/ParticipationControllerTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.participation.api; +package com.example.surveyapi.participation.api; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -23,10 +23,10 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; -import com.example.surveyapi.domain.participation.application.ParticipationQueryService; -import com.example.surveyapi.domain.participation.application.ParticipationService; -import com.example.surveyapi.domain.participation.application.dto.request.CreateParticipationRequest; -import com.example.surveyapi.domain.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.application.ParticipationQueryService; +import com.example.surveyapi.participation.application.ParticipationService; +import com.example.surveyapi.participation.application.dto.request.CreateParticipationRequest; +import com.example.surveyapi.participation.domain.command.ResponseData; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/test/java/com/example/surveyapi/domain/participation/api/ParticipationQueryControllerTest.java b/participation-module/src/test/java/com/example/surveyapi/participation/api/ParticipationQueryControllerTest.java similarity index 91% rename from src/test/java/com/example/surveyapi/domain/participation/api/ParticipationQueryControllerTest.java rename to participation-module/src/test/java/com/example/surveyapi/participation/api/ParticipationQueryControllerTest.java index 92a9f8635..12273068c 100644 --- a/src/test/java/com/example/surveyapi/domain/participation/api/ParticipationQueryControllerTest.java +++ b/participation-module/src/test/java/com/example/surveyapi/participation/api/ParticipationQueryControllerTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.participation.api; +package com.example.surveyapi.participation.api; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; @@ -26,15 +26,15 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; -import com.example.surveyapi.domain.participation.application.ParticipationQueryService; -import com.example.surveyapi.domain.participation.application.client.SurveyInfoDto; -import com.example.surveyapi.domain.participation.application.client.enums.SurveyApiStatus; -import com.example.surveyapi.domain.participation.application.dto.response.ParticipationDetailResponse; -import com.example.surveyapi.domain.participation.application.dto.response.ParticipationGroupResponse; -import com.example.surveyapi.domain.participation.application.dto.response.ParticipationInfoResponse; -import com.example.surveyapi.domain.participation.domain.command.ResponseData; -import com.example.surveyapi.domain.participation.domain.participation.query.ParticipationInfo; -import com.example.surveyapi.domain.participation.domain.participation.query.ParticipationProjection; +import com.example.surveyapi.participation.application.ParticipationQueryService; +import com.example.surveyapi.participation.application.client.SurveyInfoDto; +import com.example.surveyapi.participation.application.client.enums.SurveyApiStatus; +import com.example.surveyapi.participation.application.dto.response.ParticipationDetailResponse; +import com.example.surveyapi.participation.application.dto.response.ParticipationGroupResponse; +import com.example.surveyapi.participation.application.dto.response.ParticipationInfoResponse; +import com.example.surveyapi.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.domain.participation.query.ParticipationInfo; +import com.example.surveyapi.participation.domain.participation.query.ParticipationProjection; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/test/java/com/example/surveyapi/domain/participation/application/ParticipationServiceTest.java b/participation-module/src/test/java/com/example/surveyapi/participation/application/ParticipationServiceTest.java similarity index 90% rename from src/test/java/com/example/surveyapi/domain/participation/application/ParticipationServiceTest.java rename to participation-module/src/test/java/com/example/surveyapi/participation/application/ParticipationServiceTest.java index 5cb92df83..f4e80c0bf 100644 --- a/src/test/java/com/example/surveyapi/domain/participation/application/ParticipationServiceTest.java +++ b/participation-module/src/test/java/com/example/surveyapi/participation/application/ParticipationServiceTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.participation.application; +package com.example.surveyapi.participation.application; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -27,25 +27,25 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.PlatformTransactionManager; -import com.example.surveyapi.domain.participation.application.client.SurveyDetailDto; -import com.example.surveyapi.domain.participation.application.client.SurveyInfoDto; -import com.example.surveyapi.domain.participation.application.client.SurveyServicePort; -import com.example.surveyapi.domain.participation.application.client.UserServicePort; -import com.example.surveyapi.domain.participation.application.client.UserSnapshotDto; -import com.example.surveyapi.domain.participation.application.client.enums.SurveyApiQuestionType; -import com.example.surveyapi.domain.participation.application.client.enums.SurveyApiStatus; -import com.example.surveyapi.domain.participation.application.dto.request.CreateParticipationRequest; -import com.example.surveyapi.domain.participation.application.dto.response.ParticipationDetailResponse; -import com.example.surveyapi.domain.participation.application.dto.response.ParticipationGroupResponse; -import com.example.surveyapi.domain.participation.application.dto.response.ParticipationInfoResponse; -import com.example.surveyapi.domain.participation.domain.command.ResponseData; -import com.example.surveyapi.domain.participation.domain.participation.Participation; -import com.example.surveyapi.domain.participation.domain.participation.ParticipationRepository; -import com.example.surveyapi.domain.participation.domain.participation.enums.Gender; -import com.example.surveyapi.domain.participation.domain.participation.query.ParticipationInfo; -import com.example.surveyapi.domain.participation.domain.participation.query.ParticipationProjection; -import com.example.surveyapi.domain.participation.domain.participation.vo.ParticipantInfo; -import com.example.surveyapi.domain.participation.domain.participation.vo.Region; +import com.example.surveyapi.participation.application.client.SurveyDetailDto; +import com.example.surveyapi.participation.application.client.SurveyInfoDto; +import com.example.surveyapi.participation.application.client.SurveyServicePort; +import com.example.surveyapi.participation.application.client.UserServicePort; +import com.example.surveyapi.participation.application.client.UserSnapshotDto; +import com.example.surveyapi.participation.application.client.enums.SurveyApiQuestionType; +import com.example.surveyapi.participation.application.client.enums.SurveyApiStatus; +import com.example.surveyapi.participation.application.dto.request.CreateParticipationRequest; +import com.example.surveyapi.participation.application.dto.response.ParticipationDetailResponse; +import com.example.surveyapi.participation.application.dto.response.ParticipationGroupResponse; +import com.example.surveyapi.participation.application.dto.response.ParticipationInfoResponse; +import com.example.surveyapi.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.domain.participation.Participation; +import com.example.surveyapi.participation.domain.participation.ParticipationRepository; +import com.example.surveyapi.participation.domain.participation.enums.Gender; +import com.example.surveyapi.participation.domain.participation.query.ParticipationInfo; +import com.example.surveyapi.participation.domain.participation.query.ParticipationProjection; +import com.example.surveyapi.participation.domain.participation.vo.ParticipantInfo; +import com.example.surveyapi.participation.domain.participation.vo.Region; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/src/test/java/com/example/surveyapi/domain/participation/domain/ParticipationTest.java b/participation-module/src/test/java/com/example/surveyapi/participation/domain/ParticipationTest.java similarity index 91% rename from src/test/java/com/example/surveyapi/domain/participation/domain/ParticipationTest.java rename to participation-module/src/test/java/com/example/surveyapi/participation/domain/ParticipationTest.java index 9b8bc175f..19c06316b 100644 --- a/src/test/java/com/example/surveyapi/domain/participation/domain/ParticipationTest.java +++ b/participation-module/src/test/java/com/example/surveyapi/participation/domain/ParticipationTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.participation.domain; +package com.example.surveyapi.participation.domain; import static org.assertj.core.api.Assertions.*; @@ -10,11 +10,11 @@ import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; -import com.example.surveyapi.domain.participation.domain.command.ResponseData; -import com.example.surveyapi.domain.participation.domain.participation.Participation; -import com.example.surveyapi.domain.participation.domain.participation.enums.Gender; -import com.example.surveyapi.domain.participation.domain.participation.vo.ParticipantInfo; -import com.example.surveyapi.domain.participation.domain.participation.vo.Region; +import com.example.surveyapi.participation.domain.command.ResponseData; +import com.example.surveyapi.participation.domain.participation.Participation; +import com.example.surveyapi.participation.domain.participation.enums.Gender; +import com.example.surveyapi.participation.domain.participation.vo.ParticipantInfo; +import com.example.surveyapi.participation.domain.participation.vo.Region; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/project-module/.dockerignore b/project-module/.dockerignore new file mode 100644 index 000000000..49489b6d0 --- /dev/null +++ b/project-module/.dockerignore @@ -0,0 +1,64 @@ +user-module/ +survey-module/ +participation-module/ +statistic-module/ +share-module/ +web-app/ + +build/ +target/ +.gradle/ +out/ +bin/ + +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo + +.DS_Store +Thumbs.db +desktop.ini + +.env +.env.* +properties.env +docker/ + +*.md +docs/ +images/ +*.png +*.jpg +*.jpeg +*.gif + +test-results/ +coverage/ +*test.properties +jacoco/ + +logs/ +*.log +*.log.* + +tmp/ +temp/ +*.tmp +*.temp + +grafana/ +prometheus/ +ecs-task-definitions/ +loadtest-package/ + +.git/ +.gitignore +.gitattributes + +.github/ +.gitlab-ci.yml +.travis.yml diff --git a/project-module/Dockerfile b/project-module/Dockerfile new file mode 100644 index 000000000..9970b375e --- /dev/null +++ b/project-module/Dockerfile @@ -0,0 +1,44 @@ +FROM eclipse-temurin:17-jdk AS builder + +WORKDIR /app + +COPY gradle/ gradle/ +COPY gradlew . +COPY build.gradle . +COPY settings.gradle . + +COPY shared-kernel/build.gradle shared-kernel/ +COPY shared-kernel/src/ shared-kernel/src/ + +COPY project-module/build.gradle project-module/ +COPY project-module/src/ project-module/src/ + +RUN ./gradlew :project-module:bootJar --no-daemon + +FROM eclipse-temurin:17-jre-alpine AS runtime + +RUN apk add --no-cache curl + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder /app/project-module/build/libs/*.jar app.jar + +RUN chown appuser:appgroup app.jar + +USER appuser + +EXPOSE 8083 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8083/actuator/health || exit 1 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+ExitOnOutOfMemoryError", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] diff --git a/project-module/build.gradle b/project-module/build.gradle new file mode 100644 index 000000000..38c0ccc6a --- /dev/null +++ b/project-module/build.gradle @@ -0,0 +1,24 @@ +jar { + enabled = true + archiveClassifier = '' +} + +bootJar { + enabled = false +} + +dependencies { + // shared-kernel 의존성 + implementation project(':shared-kernel') + + // 데이터베이스 + runtimeOnly 'org.postgresql:postgresql' + + // QueryDSL + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + // 테스트 의존성 + testImplementation 'org.springframework.security:spring-security-test' +} \ No newline at end of file diff --git a/project-module/docker-compose.yml b/project-module/docker-compose.yml new file mode 100644 index 000000000..6796d5c0b --- /dev/null +++ b/project-module/docker-compose.yml @@ -0,0 +1,83 @@ +version: '3.8' + +services: + project-service: + build: + context: .. + dockerfile: project-module/Dockerfile + ports: + - "8083:8083" + environment: + - SPRING_PROFILES_ACTIVE=dev + - SERVER_PORT=8083 + - DB_HOST=postgres + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_SCHEME:-survey_db} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - RABBITMQ_HOST=rabbitmq + - RABBITMQ_PORT=${RABBITMQ_PORT:-5672} + - RABBITMQ_USERNAME=${RABBITMQ_USERNAME:-admin} + - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD:-admin} + depends_on: + postgres: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8083/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - project-network + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: ${DB_SCHEME:-survey_db} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_SCHEME:-survey_db}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - project-network + + rabbitmq: + image: rabbitmq:3.12-management + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-admin} + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] + interval: 10s + timeout: 5s + retries: 5 + command: > + sh -c " + rabbitmq-plugins enable rabbitmq_delayed_message_exchange && + docker-entrypoint.sh rabbitmq-server + " + networks: + - project-network + +volumes: + postgres_data: + rabbitmq_data: + +networks: + project-network: + driver: bridge diff --git a/project-module/src/main/java/com/example/surveyapi/project/api/ProjectController.java b/project-module/src/main/java/com/example/surveyapi/project/api/ProjectController.java new file mode 100644 index 000000000..2649481fe --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/api/ProjectController.java @@ -0,0 +1,234 @@ +package com.example.surveyapi.project.api; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.project.application.ProjectQueryService; +import com.example.surveyapi.project.application.ProjectService; +import com.example.surveyapi.project.application.dto.request.CreateProjectRequest; +import com.example.surveyapi.project.application.dto.request.SearchProjectRequest; +import com.example.surveyapi.project.application.dto.request.UpdateManagerRoleRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectOwnerRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectStateRequest; +import com.example.surveyapi.project.application.dto.response.CreateProjectResponse; +import com.example.surveyapi.project.application.dto.response.ProjectInfoResponse; +import com.example.surveyapi.project.application.dto.response.ProjectManagerInfoResponse; +import com.example.surveyapi.project.application.dto.response.ProjectMemberIdsResponse; +import com.example.surveyapi.project.application.dto.response.ProjectMemberInfoResponse; +import com.example.surveyapi.project.application.dto.response.ProjectSearchInfoResponse; +import com.example.surveyapi.global.dto.ApiResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/projects") +@RequiredArgsConstructor +public class ProjectController { + + private final ProjectService projectService; + private final ProjectQueryService projectQueryService; + + @PostMapping + public ResponseEntity> createProject( + @Valid @RequestBody CreateProjectRequest request, + @AuthenticationPrincipal Long currentUserId + ) { + CreateProjectResponse projectId = projectService.createProject(request, currentUserId); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success("프로젝트 생성 성공", projectId)); + } + + @PostMapping("/{projectId}/open") + public ResponseEntity> openProject( + @PathVariable Long projectId + ) { + projectService.openProject(projectId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 OPEN 성공")); + } + + @GetMapping("/search") + public ResponseEntity>> searchProjects( + @Valid SearchProjectRequest request, + Pageable pageable + ) { + Slice response = projectQueryService.searchProjects(request, pageable); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 검색 성공", response)); + } + + @GetMapping("/{projectId}") + public ResponseEntity> getProject( + @PathVariable Long projectId + ) { + ProjectInfoResponse response = projectQueryService.getProject(projectId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 상세정보 조회", response)); + } + + @PutMapping("/{projectId}") + public ResponseEntity> updateProject( + @PathVariable Long projectId, + @Valid @RequestBody UpdateProjectRequest request + ) { + projectService.updateProject(projectId, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 정보 수정 성공")); + } + + @PatchMapping("/{projectId}/state") + public ResponseEntity> updateState( + @PathVariable Long projectId, + @Valid @RequestBody UpdateProjectStateRequest request + ) { + projectService.updateState(projectId, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 상태 변경 성공")); + } + + @PatchMapping("/{projectId}/owner") + public ResponseEntity> updateOwner( + @PathVariable Long projectId, + @Valid @RequestBody UpdateProjectOwnerRequest request, + @AuthenticationPrincipal Long currentUserId + ) { + projectService.updateOwner(projectId, request, currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 소유자 위임 성공")); + } + + @DeleteMapping("/{projectId}") + public ResponseEntity> deleteProject( + @PathVariable Long projectId, + @AuthenticationPrincipal Long currentUserId + ) { + projectService.deleteProject(projectId, currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 삭제 성공")); + } + + // Project Manager + @GetMapping("/me/managers") + public ResponseEntity>> getMyProjectsAsManager( + @AuthenticationPrincipal Long currentUserId + ) { + List result = projectQueryService.getMyProjectsAsManager(currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("담당자로 참여한 프로젝트 조회 성공", result)); + } + + @GetMapping("/{projectId}/managers") + public ResponseEntity> joinProjectManager( + @PathVariable Long projectId, + @AuthenticationPrincipal Long currentUserId + ) { + projectService.joinProjectManager(projectId, currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("담당자로 참여 성공")); + } + + @PatchMapping("/{projectId}/managers/{managerId}/role") + public ResponseEntity> updateManagerRole( + @PathVariable Long projectId, + @PathVariable Long managerId, + @Valid @RequestBody UpdateManagerRoleRequest request, + @AuthenticationPrincipal Long currentUserId + ) { + projectService.updateManagerRole(projectId, managerId, request, currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("담당자 권한 수정 성공")); + } + + @DeleteMapping("/{projectId}/managers") + public ResponseEntity> leaveProjectManager( + @PathVariable Long projectId, + @AuthenticationPrincipal Long currentUserId + ) { + projectService.leaveProjectManager(projectId, currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 매니저 탈퇴 성공")); + } + + @DeleteMapping("/{projectId}/managers/{managerId}") + public ResponseEntity> deleteManager( + @PathVariable Long projectId, + @PathVariable Long managerId, + @AuthenticationPrincipal Long currentUserId + ) { + projectService.deleteManager(projectId, managerId, currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("담당자 삭제 성공")); + } + + // ProjectMember + @GetMapping("/me/members") + public ResponseEntity>> getMyProjectsAsMember( + @AuthenticationPrincipal Long currentUserId + ) { + List result = projectQueryService.getMyProjectsAsMember(currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("멤버로 참여한 프로젝트 조회 성공", result)); + } + + @GetMapping("/{projectId}/members/join") + public ResponseEntity> joinProjectMember( + @PathVariable Long projectId, + @AuthenticationPrincipal Long currentUserId + ) { + projectService.joinProjectMember(projectId, currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 참여 성공")); + } + + @GetMapping("/{projectId}/members") + public ResponseEntity> getProjectMemberIds( + @PathVariable Long projectId + ) { + ProjectMemberIdsResponse response = projectQueryService.getProjectMemberIds(projectId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 참여 인원 조회 성공", response)); + } + + @DeleteMapping("/{projectId}/members") + public ResponseEntity> leaveProjectMember( + @PathVariable Long projectId, + @AuthenticationPrincipal Long currentUserId + ) { + projectService.leaveProjectMember(projectId, currentUserId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("프로젝트 멤버 탈퇴 성공")); + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/ProjectQueryService.java b/project-module/src/main/java/com/example/surveyapi/project/application/ProjectQueryService.java new file mode 100644 index 000000000..23e238c3d --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/ProjectQueryService.java @@ -0,0 +1,73 @@ +package com.example.surveyapi.project.application; + +import java.util.List; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.project.application.dto.request.SearchProjectRequest; +import com.example.surveyapi.project.application.dto.response.ProjectInfoResponse; +import com.example.surveyapi.project.application.dto.response.ProjectManagerInfoResponse; +import com.example.surveyapi.project.application.dto.response.ProjectMemberIdsResponse; +import com.example.surveyapi.project.application.dto.response.ProjectMemberInfoResponse; +import com.example.surveyapi.project.application.dto.response.ProjectSearchInfoResponse; +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.repository.ProjectRepository; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ProjectQueryService { + + private final ProjectRepository projectRepository; + + @Transactional(readOnly = true) + public List getMyProjectsAsManager(Long currentUserId) { + + return projectRepository.findMyProjectsAsManager(currentUserId) + .stream() + .map(ProjectManagerInfoResponse::from) + .toList(); + } + + @Transactional(readOnly = true) + public List getMyProjectsAsMember(Long currentUserId) { + + return projectRepository.findMyProjectsAsMember(currentUserId) + .stream() + .map(ProjectMemberInfoResponse::from) + .toList(); + } + + @Transactional(readOnly = true) + public Slice searchProjects(SearchProjectRequest request, Pageable pageable) { + + return projectRepository.searchProjectsNoOffset(request.getKeyword(), request.getLastProjectId(), pageable) + .map(ProjectSearchInfoResponse::from); + } + + @Transactional(readOnly = true) + public ProjectInfoResponse getProject(Long projectId) { + Project project = findByIdOrElseThrow(projectId); + + return ProjectInfoResponse.from(project); + } + + @Transactional(readOnly = true) + public ProjectMemberIdsResponse getProjectMemberIds(Long projectId) { + Project project = findByIdOrElseThrow(projectId); + + return ProjectMemberIdsResponse.from(project); + } + + private Project findByIdOrElseThrow(Long projectId) { + + return projectRepository.findByIdAndIsDeletedFalse(projectId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_PROJECT)); + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/ProjectService.java b/project-module/src/main/java/com/example/surveyapi/project/application/ProjectService.java new file mode 100644 index 000000000..0ffea5404 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/ProjectService.java @@ -0,0 +1,169 @@ +package com.example.surveyapi.project.application; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.project.application.dto.request.CreateProjectRequest; +import com.example.surveyapi.project.application.dto.request.UpdateManagerRoleRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectOwnerRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectStateRequest; +import com.example.surveyapi.project.application.dto.response.CreateProjectResponse; +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.repository.ProjectRepository; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProjectService { + + private final ProjectRepository projectRepository; + + @Transactional + public CreateProjectResponse createProject(CreateProjectRequest request, Long currentUserId) { + validateDuplicateName(request.getName()); + + Project project = Project.create( + request.getName(), + request.getDescription(), + currentUserId, + request.getMaxMembers(), + request.getPeriodStart(), + request.getPeriodEnd() + ); + projectRepository.save(project); + + return CreateProjectResponse.of(project.getId(), project.getMaxMembers()); + } + + @Transactional + public void openProject(Long projectId) { + Project project = findByIdOrElseThrow(projectId); + project.openProject(); + projectRepository.save(project); + } + + @Transactional + public void updateProject(Long projectId, UpdateProjectRequest request) { + Project project = findByIdOrElseThrow(projectId); + + if (request.getName() != null && !request.getName().equals(project.getName())) { + validateDuplicateName(request.getName()); + } + + project.updateProject( + request.getName(), request.getDescription(), + request.getPeriodStart(), request.getPeriodEnd() + ); + projectRepository.save(project); + } + + @Transactional + public void updateState(Long projectId, UpdateProjectStateRequest request) { + Project project = findByIdOrElseThrow(projectId); + project.updateState(request.getState()); + projectRepository.save(project); + } + + @Transactional + public void updateOwner(Long projectId, UpdateProjectOwnerRequest request, Long currentUserId) { + Project project = findByIdOrElseThrow(projectId); + project.updateOwner(currentUserId, request.getNewOwnerId()); + projectRepository.save(project); + } + + @Transactional + public void deleteProject(Long projectId, Long currentUserId) { + Project project = findByIdOrElseThrow(projectId); + project.softDelete(currentUserId); + projectRepository.save(project); + } + + @Transactional + public void joinProjectManager(Long projectId, Long currentUserId) { + Project project = findByIdOrElseThrow(projectId); + project.addManager(currentUserId); + projectRepository.save(project); + } + + @Transactional + public void updateManagerRole(Long projectId, Long managerId, UpdateManagerRoleRequest request, + Long currentUserId) { + Project project = findByIdOrElseThrow(projectId); + project.updateManagerRole(currentUserId, managerId, request.getNewRole()); + projectRepository.save(project); + } + + @Transactional + public void deleteManager(Long projectId, Long managerId, Long currentUserId) { + Project project = findByIdOrElseThrow(projectId); + project.deleteManager(currentUserId, managerId); + projectRepository.save(project); + } + + @Transactional + public void joinProjectMember(Long projectId, Long currentUserId) { + Project project = findByIdOrElseThrow(projectId); + project.addMember(currentUserId); + projectRepository.save(project); + } + + @Transactional + public void leaveProjectManager(Long projectId, Long currentUserId) { + Project project = findByIdOrElseThrow(projectId); + project.removeManager(currentUserId); + projectRepository.save(project); + } + + @Transactional + public void leaveProjectMember(Long projectId, Long currentUserId) { + Project project = findByIdOrElseThrow(projectId); + project.removeMember(currentUserId); + projectRepository.save(project); + } + + @Transactional + public void handleUserWithdraw(Long userId) { + // 카테시안 곱 발생 + List projects = projectRepository.findAllWithParticipantsByUserId(userId); + + for (Project project : projects) { + boolean isManager = project.getProjectManagers().stream() + .anyMatch(m -> m.isSameUser(userId) && !m.getIsDeleted()); + if (isManager) { + project.removeManager(userId); + } + + boolean isMember = project.getProjectMembers().stream() + .anyMatch(m -> m.isSameUser(userId) && !m.getIsDeleted()); + if (isMember) { + project.removeMember(userId); + } + + if (project.getOwnerId().equals(userId)) { + project.softDelete(userId); + } + } + + projectRepository.saveAll(projects); + } + + private void validateDuplicateName(String name) { + if (projectRepository.existsByNameAndIsDeletedFalse(name)) { + throw new CustomException(CustomErrorCode.DUPLICATE_PROJECT_NAME); + } + } + + private Project findByIdOrElseThrow(Long projectId) { + + return projectRepository.findByIdAndIsDeletedFalse(projectId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_PROJECT)); + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/ProjectStateScheduler.java b/project-module/src/main/java/com/example/surveyapi/project/application/ProjectStateScheduler.java new file mode 100644 index 000000000..0a6c902de --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/ProjectStateScheduler.java @@ -0,0 +1,60 @@ +package com.example.surveyapi.project.application; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.enums.ProjectState; +import com.example.surveyapi.project.domain.project.event.ProjectStateChangedDomainEvent; +import com.example.surveyapi.project.domain.project.repository.ProjectRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ProjectStateScheduler { + + private final ProjectRepository projectRepository; + + @Scheduled(cron = "0 0 0 * * *") // 매일 00시 실행 + @Transactional + public void updateProjectStates() { + LocalDateTime now = LocalDateTime.now(); + updatePendingProjects(now); + updateInProgressProjects(now); + } + + private void updatePendingProjects(LocalDateTime now) { + List pendingProjects = projectRepository.findPendingProjectsToStart(now); + if (pendingProjects.isEmpty()) { + return; + } + + List projectIds = pendingProjects.stream().map(Project::getId).toList(); + projectRepository.updateStateByIds(projectIds, ProjectState.IN_PROGRESS); + + for (Project project : pendingProjects) { + project.registerEvent(new ProjectStateChangedDomainEvent(project.getId(), project.getState())); + } + projectRepository.saveAll(pendingProjects); + } + + private void updateInProgressProjects(LocalDateTime now) { + List inProgressProjects = projectRepository.findInProgressProjectsToClose(now); + if (inProgressProjects.isEmpty()) { + return; + } + + List projectIds = inProgressProjects.stream().map(Project::getId).toList(); + projectRepository.updateStateByIds(projectIds, ProjectState.CLOSED); + + for (Project project : inProgressProjects) { + project.registerEvent(new ProjectStateChangedDomainEvent(project.getId(), project.getState())); + } + projectRepository.saveAll(inProgressProjects); + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/CreateProjectRequest.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/CreateProjectRequest.java new file mode 100644 index 000000000..c214d1c63 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/CreateProjectRequest.java @@ -0,0 +1,30 @@ +package com.example.surveyapi.project.application.dto.request; + +import java.time.LocalDateTime; + +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class CreateProjectRequest { + + @NotBlank(message = "이름을 입력해주세요") + private String name; + + @NotBlank(message = "설명을 입력해주세요") + private String description; + + @NotNull(message = "시작일을 입력해주세요") + @Future(message = "시작일은 현재보다 이후여야 합니다.") + private LocalDateTime periodStart; + + private LocalDateTime periodEnd; + + @Min(value = 1, message = "최대 인원수는 최소 1명 이상이어야 합니다.") + @Max(value = 500, message = "최대 인원수는 500명을 초과할 수 없습니다.") + private int maxMembers; +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/SearchProjectRequest.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/SearchProjectRequest.java new file mode 100644 index 000000000..bbfb7e537 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/SearchProjectRequest.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.project.application.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class SearchProjectRequest { + @Size(min = 3, message = "검색어는 최소 3글자 이상이어야 합니다.") + private String keyword; + private Long lastProjectId; +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateManagerRoleRequest.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateManagerRoleRequest.java new file mode 100644 index 000000000..6eeab2fc1 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateManagerRoleRequest.java @@ -0,0 +1,12 @@ +package com.example.surveyapi.project.application.dto.request; + +import com.example.surveyapi.project.domain.participant.manager.enums.ManagerRole; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class UpdateManagerRoleRequest { + @NotNull(message = "변경할 권한을 입력해주세요") + private ManagerRole newRole; +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectOwnerRequest.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectOwnerRequest.java new file mode 100644 index 000000000..0d5b8ed21 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectOwnerRequest.java @@ -0,0 +1,10 @@ +package com.example.surveyapi.project.application.dto.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class UpdateProjectOwnerRequest { + @NotNull(message = "위임할 회원 ID를 입력해주세요") + private Long newOwnerId; +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectRequest.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectRequest.java new file mode 100644 index 000000000..502e1a416 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectRequest.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.project.application.dto.request; + +import java.time.LocalDateTime; + +import lombok.Getter; + +@Getter +public class UpdateProjectRequest { + private String name; + private String description; + private LocalDateTime periodStart; + private LocalDateTime periodEnd; +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectStateRequest.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectStateRequest.java new file mode 100644 index 000000000..eb32220e1 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/request/UpdateProjectStateRequest.java @@ -0,0 +1,12 @@ +package com.example.surveyapi.project.application.dto.request; + +import com.example.surveyapi.project.domain.project.enums.ProjectState; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class UpdateProjectStateRequest { + @NotNull(message = "변경할 상태를 입력해주세요") + private ProjectState state; +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/CreateProjectResponse.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/CreateProjectResponse.java new file mode 100644 index 000000000..69c00c80e --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/CreateProjectResponse.java @@ -0,0 +1,18 @@ +package com.example.surveyapi.project.application.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class CreateProjectResponse { + private Long projectId; + private int maxMembers; + + public static CreateProjectResponse of(Long projectId, int maxMembers) { + return new CreateProjectResponse(projectId, maxMembers); + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectInfoResponse.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectInfoResponse.java new file mode 100644 index 000000000..eddb2eb04 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectInfoResponse.java @@ -0,0 +1,42 @@ +package com.example.surveyapi.project.application.dto.response; + +import java.time.LocalDateTime; + +import com.example.surveyapi.project.domain.project.entity.Project; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProjectInfoResponse { + private Long projectId; + private String name; + private String description; + private Long ownerId; + private LocalDateTime periodStart; + private LocalDateTime periodEnd; + private String state; + private int maxMembers; + private int currentMemberCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static ProjectInfoResponse from(Project project) { + ProjectInfoResponse response = new ProjectInfoResponse(); + response.projectId = project.getId(); + response.name = project.getName(); + response.description = project.getDescription(); + response.ownerId = project.getOwnerId(); + response.periodStart = project.getPeriod().getPeriodStart(); + response.periodEnd = project.getPeriod().getPeriodEnd(); + response.state = project.getState().name(); + response.maxMembers = project.getMaxMembers(); + response.currentMemberCount = project.getCurrentMemberCount(); + response.createdAt = project.getCreatedAt(); + response.updatedAt = project.getUpdatedAt(); + + return response; + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectManagerInfoResponse.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectManagerInfoResponse.java new file mode 100644 index 000000000..d03951aec --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectManagerInfoResponse.java @@ -0,0 +1,42 @@ +package com.example.surveyapi.project.application.dto.response; + +import java.time.LocalDateTime; + +import com.example.surveyapi.project.domain.dto.ProjectManagerResult; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProjectManagerInfoResponse { + private Long projectId; + private String name; + private String description; + private Long ownerId; + private String myRole; + private LocalDateTime periodStart; + private LocalDateTime periodEnd; + private String state; + private int managersCount; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static ProjectManagerInfoResponse from(ProjectManagerResult projectManagerResult) { + ProjectManagerInfoResponse response = new ProjectManagerInfoResponse(); + response.projectId = projectManagerResult.getProjectId(); + response.name = projectManagerResult.getName(); + response.description = projectManagerResult.getDescription(); + response.ownerId = projectManagerResult.getOwnerId(); + response.myRole = projectManagerResult.getMyRole(); + response.periodStart = projectManagerResult.getPeriodStart(); + response.periodEnd = projectManagerResult.getPeriodEnd(); + response.state = projectManagerResult.getState(); + response.managersCount = projectManagerResult.getManagersCount(); + response.createdAt = projectManagerResult.getCreatedAt(); + response.updatedAt = projectManagerResult.getUpdatedAt(); + + return response; + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectMemberIdsResponse.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectMemberIdsResponse.java new file mode 100644 index 000000000..1839c4dbd --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectMemberIdsResponse.java @@ -0,0 +1,31 @@ +package com.example.surveyapi.project.application.dto.response; + +import java.util.List; + +import com.example.surveyapi.project.domain.participant.member.entity.ProjectMember; +import com.example.surveyapi.project.domain.project.entity.Project; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProjectMemberIdsResponse { + private int currentMemberCount; // 현재 인원수 + private int maxMembers; // 최대 인원수 + private List memberIds; // 참여한 유저 id 리스트 + + public static ProjectMemberIdsResponse from(Project project) { + List ids = project.getProjectMembers().stream() + .map(ProjectMember::getUserId) + .toList(); + + ProjectMemberIdsResponse response = new ProjectMemberIdsResponse(); + response.currentMemberCount = ids.size(); + response.maxMembers = project.getMaxMembers(); + response.memberIds = ids; + + return response; + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectMemberInfoResponse.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectMemberInfoResponse.java new file mode 100644 index 000000000..a87c06270 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectMemberInfoResponse.java @@ -0,0 +1,43 @@ +package com.example.surveyapi.project.application.dto.response; + +import java.time.LocalDateTime; + +import com.example.surveyapi.project.domain.dto.ProjectMemberResult; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProjectMemberInfoResponse { + private Long projectId; + private String name; + private String description; + private Long ownerId; + private LocalDateTime periodStart; + private LocalDateTime periodEnd; + private String state; + private int currentMemberCount; + private int maxMembers; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static ProjectMemberInfoResponse from(ProjectMemberResult projectMemberResult) { + ProjectMemberInfoResponse response = new ProjectMemberInfoResponse(); + response.projectId = projectMemberResult.getProjectId(); + response.name = projectMemberResult.getName(); + response.description = projectMemberResult.getDescription(); + response.ownerId = projectMemberResult.getOwnerId(); + response.periodStart = projectMemberResult.getPeriodStart(); + response.periodEnd = projectMemberResult.getPeriodEnd(); + response.state = projectMemberResult.getState(); + response.currentMemberCount = projectMemberResult.getCurrentMemberCount(); + response.maxMembers = projectMemberResult.getMaxMembers(); + response.createdAt = projectMemberResult.getCreatedAt(); + response.updatedAt = projectMemberResult.getUpdatedAt(); + + return response; + } +} + diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectSearchInfoResponse.java b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectSearchInfoResponse.java new file mode 100644 index 000000000..73f0dfb26 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/dto/response/ProjectSearchInfoResponse.java @@ -0,0 +1,34 @@ +package com.example.surveyapi.project.application.dto.response; + +import java.time.LocalDateTime; + +import com.example.surveyapi.project.domain.dto.ProjectSearchResult; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProjectSearchInfoResponse { + private Long projectId; + private String name; + private String description; + private Long ownerId; + private String state; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static ProjectSearchInfoResponse from(ProjectSearchResult result) { + ProjectSearchInfoResponse response = new ProjectSearchInfoResponse(); + response.projectId = result.getProjectId(); + response.name = result.getName(); + response.description = result.getDescription(); + response.ownerId = result.getOwnerId(); + response.state = result.getState(); + response.createdAt = result.getCreatedAt(); + response.updatedAt = result.getUpdatedAt(); + + return response; + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventListener.java b/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventListener.java new file mode 100644 index 000000000..99efe287b --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventListener.java @@ -0,0 +1,48 @@ +package com.example.surveyapi.project.application.event; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.example.surveyapi.project.domain.project.event.ProjectCreatedDomainEvent; +import com.example.surveyapi.project.domain.project.event.ProjectDeletedDomainEvent; +import com.example.surveyapi.project.domain.project.event.ProjectStateChangedDomainEvent; + +import com.example.surveyapi.global.event.project.ProjectCreatedEvent; +import com.example.surveyapi.global.event.project.ProjectDeletedEvent; +import com.example.surveyapi.global.event.project.ProjectStateChangedEvent; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProjectEventListener { + + private final ProjectEventPublisher projectEventPublisher; + private final ObjectMapper objectMapper; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProjectCreated(ProjectCreatedDomainEvent internalEvent) { + ProjectCreatedEvent globalEvent = objectMapper.convertValue(internalEvent, ProjectCreatedEvent.class); + projectEventPublisher.convertAndSend(globalEvent); + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProjectStateChanged(ProjectStateChangedDomainEvent internalEvent) { + ProjectStateChangedEvent globalEvent = objectMapper.convertValue(internalEvent, ProjectStateChangedEvent.class); + projectEventPublisher.convertAndSend(globalEvent); + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleProjectDeleted(ProjectDeletedDomainEvent internalEvent) { + ProjectDeletedEvent globalEvent = objectMapper.convertValue(internalEvent, ProjectDeletedEvent.class); + projectEventPublisher.convertAndSend(globalEvent); + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventListenerPort.java b/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventListenerPort.java new file mode 100644 index 000000000..725d48721 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventListenerPort.java @@ -0,0 +1,6 @@ +package com.example.surveyapi.project.application.event; + +public interface ProjectEventListenerPort { + + void handleUserWithdrawEvent(Long userId); +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventPublisher.java b/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventPublisher.java new file mode 100644 index 000000000..ec35851a1 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectEventPublisher.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.project.application.event; + +import com.example.surveyapi.global.event.project.ProjectEvent; + +public interface ProjectEventPublisher { + void convertAndSend(ProjectEvent event); +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectHandlerEvent.java b/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectHandlerEvent.java new file mode 100644 index 000000000..9f7226ab8 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/application/event/ProjectHandlerEvent.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.project.application.event; + +import org.springframework.stereotype.Service; + +import com.example.surveyapi.project.application.ProjectService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ProjectHandlerEvent implements ProjectEventListenerPort { + + private final ProjectService projectService; + + @Override + public void handleUserWithdrawEvent(Long userId) { + projectService.handleUserWithdraw(userId); + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectManagerResult.java b/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectManagerResult.java new file mode 100644 index 000000000..70367aa50 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectManagerResult.java @@ -0,0 +1,39 @@ +package com.example.surveyapi.project.domain.dto; + +import java.time.LocalDateTime; + +import com.querydsl.core.annotations.QueryProjection; + +import lombok.Getter; + +@Getter +public class ProjectManagerResult { + private final Long projectId; + private final String name; + private final String description; + private final Long ownerId; + private final String myRole; + private final LocalDateTime periodStart; + private final LocalDateTime periodEnd; + private final String state; + private final int managersCount; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + @QueryProjection + public ProjectManagerResult(Long projectId, String name, String description, Long ownerId, String myRole, + LocalDateTime periodStart, LocalDateTime periodEnd, String state, int managersCount, LocalDateTime createdAt, + LocalDateTime updatedAt) { + this.projectId = projectId; + this.name = name; + this.description = description; + this.ownerId = ownerId; + this.myRole = myRole; + this.periodStart = periodStart; + this.periodEnd = periodEnd; + this.state = state; + this.managersCount = managersCount; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectMemberResult.java b/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectMemberResult.java new file mode 100644 index 000000000..02dab5b4c --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectMemberResult.java @@ -0,0 +1,39 @@ +package com.example.surveyapi.project.domain.dto; + +import java.time.LocalDateTime; + +import com.querydsl.core.annotations.QueryProjection; + +import lombok.Getter; + +@Getter +public class ProjectMemberResult { + private final Long projectId; + private final String name; + private final String description; + private final Long ownerId; + private final LocalDateTime periodStart; + private final LocalDateTime periodEnd; + private final String state; + private final int currentMemberCount; + private final int maxMembers; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + @QueryProjection + public ProjectMemberResult(Long projectId, String name, String description, Long ownerId, LocalDateTime periodStart, + LocalDateTime periodEnd, String state, int currentMemberCount, int maxMembers, + LocalDateTime createdAt, LocalDateTime updatedAt) { + this.projectId = projectId; + this.name = name; + this.description = description; + this.ownerId = ownerId; + this.periodStart = periodStart; + this.periodEnd = periodEnd; + this.state = state; + this.currentMemberCount = currentMemberCount; + this.maxMembers = maxMembers; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectSearchResult.java b/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectSearchResult.java new file mode 100644 index 000000000..1ffd83c64 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/dto/ProjectSearchResult.java @@ -0,0 +1,30 @@ +package com.example.surveyapi.project.domain.dto; + +import java.time.LocalDateTime; + +import com.querydsl.core.annotations.QueryProjection; + +import lombok.Getter; + +@Getter +public class ProjectSearchResult { + private final Long projectId; + private final String name; + private final String description; + private final Long ownerId; + private final String state; + private final LocalDateTime createdAt; + private final LocalDateTime updatedAt; + + @QueryProjection + public ProjectSearchResult(Long projectId, String name, String description, Long ownerId, + String state, LocalDateTime createdAt, LocalDateTime updatedAt) { + this.projectId = projectId; + this.name = name; + this.description = description; + this.ownerId = ownerId; + this.state = state; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/participant/ProjectParticipant.java b/project-module/src/main/java/com/example/surveyapi/project/domain/participant/ProjectParticipant.java new file mode 100644 index 000000000..65b64a8d2 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/participant/ProjectParticipant.java @@ -0,0 +1,35 @@ +package com.example.surveyapi.project.domain.participant; + +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.global.model.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@MappedSuperclass +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public abstract class ProjectParticipant extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "project_id", nullable = false) + protected Project project; + + @Column(nullable = false) + protected Long userId; + + protected ProjectParticipant(Project project, Long userId) { + this.project = project; + this.userId = userId; + } + + public boolean isSameUser(Long userId) { + return this.userId.equals(userId); + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/participant/manager/entity/ProjectManager.java b/project-module/src/main/java/com/example/surveyapi/project/domain/participant/manager/entity/ProjectManager.java new file mode 100644 index 000000000..0ded24f7c --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/participant/manager/entity/ProjectManager.java @@ -0,0 +1,58 @@ +package com.example.surveyapi.project.domain.participant.manager.entity; + +import com.example.surveyapi.project.domain.participant.ProjectParticipant; +import com.example.surveyapi.project.domain.participant.manager.enums.ManagerRole; +import com.example.surveyapi.project.domain.project.entity.Project; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "project_managers") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectManager extends ProjectParticipant { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ManagerRole role; + + public static ProjectManager create(Project project, Long userId) { + ProjectManager projectManager = new ProjectManager(project, userId); + projectManager.role = ManagerRole.READ; + + return projectManager; + } + + public static ProjectManager createOwner(Project project, Long userId) { + ProjectManager projectManager = new ProjectManager(project, userId); + projectManager.role = ManagerRole.OWNER; + + return projectManager; + } + + private ProjectManager(Project project, Long userId) { + super(project, userId); + } + + public void updateRole(ManagerRole role) { + this.role = role; + } + + public boolean isOwner() { + return this.role == ManagerRole.OWNER; + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/participant/manager/enums/ManagerRole.java b/project-module/src/main/java/com/example/surveyapi/project/domain/participant/manager/enums/ManagerRole.java new file mode 100644 index 000000000..560b7419f --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/participant/manager/enums/ManagerRole.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.project.domain.participant.manager.enums; + +public enum ManagerRole { + READ, + WRITE, + STAT, + OWNER +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/participant/member/entity/ProjectMember.java b/project-module/src/main/java/com/example/surveyapi/project/domain/participant/member/entity/ProjectMember.java new file mode 100644 index 000000000..2e716f513 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/participant/member/entity/ProjectMember.java @@ -0,0 +1,32 @@ +package com.example.surveyapi.project.domain.participant.member.entity; + +import com.example.surveyapi.project.domain.participant.ProjectParticipant; +import com.example.surveyapi.project.domain.project.entity.Project; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "project_members") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProjectMember extends ProjectParticipant { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + public static ProjectMember create(Project project, Long userId) { + return new ProjectMember(project, userId); + } + + private ProjectMember(Project project, Long userId) { + super(project, userId); + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/entity/Project.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/entity/Project.java new file mode 100644 index 000000000..d934dd685 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/project/entity/Project.java @@ -0,0 +1,281 @@ +package com.example.surveyapi.project.domain.project.entity; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import com.example.surveyapi.project.domain.participant.manager.entity.ProjectManager; +import com.example.surveyapi.project.domain.participant.manager.enums.ManagerRole; +import com.example.surveyapi.project.domain.participant.member.entity.ProjectMember; +import com.example.surveyapi.project.domain.project.enums.ProjectState; +import com.example.surveyapi.project.domain.project.event.ProjectCreatedDomainEvent; +import com.example.surveyapi.project.domain.project.event.ProjectDeletedDomainEvent; +import com.example.surveyapi.project.domain.project.event.ProjectStateChangedDomainEvent; +import com.example.surveyapi.project.domain.project.vo.ProjectPeriod; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; +import com.example.surveyapi.global.model.AbstractRoot; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 애그리거트 루트 + */ +@Entity +@Table(name = "projects") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Project extends AbstractRoot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Version + private Long version; + + @Column(nullable = false, unique = true) + private String name; + + @Column(columnDefinition = "TEXT", nullable = false) + private String description; + + @Column(nullable = false) + private Long ownerId; + + @Embedded + private ProjectPeriod period; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ProjectState state = ProjectState.PENDING; + + @Column(nullable = false) + private int maxMembers; + + @OneToMany(mappedBy = "project", cascade = {CascadeType.MERGE, CascadeType.PERSIST}) + private List projectManagers = new ArrayList<>(); + + @OneToMany(mappedBy = "project", cascade = {CascadeType.MERGE, CascadeType.PERSIST}) + private List projectMembers = new ArrayList<>(); + + public static Project create(String name, String description, Long ownerId, int maxMembers, + LocalDateTime periodStart, LocalDateTime periodEnd) { + ProjectPeriod period = ProjectPeriod.of(periodStart, periodEnd); + + Project project = new Project(); + project.name = name; + project.description = description; + project.ownerId = ownerId; + project.period = period; + project.maxMembers = maxMembers; + // 프로젝트 생성자는 소유자로 등록 + project.projectManagers.add(ProjectManager.createOwner(project, ownerId)); + + return project; + } + + public void openProject() { + // PENDING -> IN_PROGRESS만 허용 periodStart를 now로 세팅 + if (this.state != ProjectState.PENDING) { + throw new CustomException(CustomErrorCode.INVALID_STATE_TRANSITION); + } + this.period = ProjectPeriod.of(LocalDateTime.now(), this.period.getPeriodEnd()); + this.state = ProjectState.IN_PROGRESS; + + registerEvent(new ProjectCreatedDomainEvent(this.id, this.ownerId, this.getPeriod().getPeriodEnd())); + } + + public void updateProject(String newName, String newDescription, LocalDateTime newPeriodStart, + LocalDateTime newPeriodEnd) { + if (newPeriodStart != null || newPeriodEnd != null) { + LocalDateTime start = Objects.requireNonNullElse(newPeriodStart, this.period.getPeriodStart()); + LocalDateTime end = Objects.requireNonNullElse(newPeriodEnd, this.period.getPeriodEnd()); + this.period = ProjectPeriod.of(start, end); + } + if (newName != null && !newName.trim().isEmpty()) { + this.name = newName; + } + if (newDescription != null && !newDescription.trim().isEmpty()) { + this.description = newDescription; + } + } + + public void updateState(ProjectState newState) { + // PENDING -> IN_PROGRESS만 허용 periodStart를 now로 세팅 + if (this.state == ProjectState.PENDING) { + if (newState != ProjectState.IN_PROGRESS) { + throw new CustomException(CustomErrorCode.INVALID_STATE_TRANSITION); + } + this.period = ProjectPeriod.of(LocalDateTime.now(), this.period.getPeriodEnd()); + } + // IN_PROGRESS -> CLOSED만 허용 periodEnd를 now로 세팅 + if (this.state == ProjectState.IN_PROGRESS) { + if (newState != ProjectState.CLOSED) { + throw new CustomException(CustomErrorCode.INVALID_STATE_TRANSITION); + } + this.period = ProjectPeriod.of(this.period.getPeriodStart(), LocalDateTime.now()); + } + + this.state = newState; + registerEvent(new ProjectStateChangedDomainEvent(this.id, newState)); + } + + public void updateOwner(Long currentUserId, Long newOwnerId) { + checkOwner(currentUserId); + + ProjectManager previousOwner = findManagerByUserId(this.ownerId); + ProjectManager newOwner = findManagerByUserId(newOwnerId); + + if (previousOwner.isSameUser(newOwnerId)) { + throw new CustomException(CustomErrorCode.CANNOT_TRANSFER_TO_SELF); + } + + newOwner.updateRole(ManagerRole.OWNER); + previousOwner.updateRole(ManagerRole.READ); + this.ownerId = newOwnerId; + } + + public void softDelete(Long currentUserId) { + checkOwner(currentUserId); + this.state = ProjectState.CLOSED; + + // 기존 프로젝트 담당자 같이 삭제 + if (this.projectManagers != null) { + this.projectManagers.forEach(ProjectManager::delete); + } + + // 기존 프로젝트 참여자 같이 삭제 + if (this.projectMembers != null) { + this.projectMembers.forEach(ProjectMember::delete); + } + + this.delete(); + registerEvent(new ProjectDeletedDomainEvent(this.id, this.name, currentUserId)); + } + + public void addManager(Long currentUserId) { + // 중복 가입 체크 + boolean exists = this.projectManagers.stream() + .anyMatch(manager -> manager.isSameUser(currentUserId) && !manager.getIsDeleted()); + if (exists) { + throw new CustomException(CustomErrorCode.ALREADY_REGISTERED_MANAGER); + } + + ProjectManager newProjectManager = ProjectManager.create(this, currentUserId); + this.projectManagers.add(newProjectManager); + } + + public void updateManagerRole(Long currentUserId, Long managerId, ManagerRole newRole) { + checkOwner(currentUserId); + ProjectManager projectManager = findManagerById(managerId); + + // 본인 OWNER 권한 변경 불가 + if (projectManager.isSameUser(currentUserId)) { + throw new CustomException(CustomErrorCode.CANNOT_CHANGE_OWNER_ROLE); + } + + if (newRole == ManagerRole.OWNER) { + throw new CustomException(CustomErrorCode.CANNOT_CHANGE_OWNER_ROLE); + } + + // 현재 소유자인 경우 권한 변경 불가 + if (projectManager.isOwner()) { + throw new CustomException(CustomErrorCode.CANNOT_CHANGE_OWNER_ROLE); + } + + projectManager.updateRole(newRole); + } + + public void deleteManager(Long currentUserId, Long managerId) { + checkOwner(currentUserId); + ProjectManager projectManager = findManagerById(managerId); + + if (projectManager.isSameUser(currentUserId)) { + throw new CustomException(CustomErrorCode.CANNOT_DELETE_SELF_OWNER); + } + + projectManager.delete(); + } + + public void removeManager(Long currentUserId) { + ProjectManager manager = findManagerByUserId(currentUserId); + manager.delete(); + } + + // Manager 조회 헬퍼 메소드 + public ProjectManager findManagerByUserId(Long userId) { + + return this.projectManagers.stream() + .filter(manager -> manager.isSameUser(userId) && !manager.getIsDeleted()) + .findFirst() + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_MANAGER)); + } + + public ProjectManager findManagerById(Long managerId) { + + return this.projectManagers.stream() + .filter(manager -> Objects.equals(manager.getId(), managerId)) + .findFirst() + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_MANAGER)); + } + + public void addMember(Long currentUserId) { + // 중복 가입 체크 + boolean exists = this.projectMembers.stream() + .anyMatch( + member -> member.isSameUser(currentUserId) && !member.getIsDeleted()); + if (exists) { + throw new CustomException(CustomErrorCode.ALREADY_REGISTERED_MEMBER); + } + + // 최대 인원수 체크 + if (getCurrentMemberCount() >= this.maxMembers) { + throw new CustomException(CustomErrorCode.PROJECT_MEMBER_LIMIT_EXCEEDED); + } + + this.projectMembers.add(ProjectMember.create(this, currentUserId)); + } + + public void removeMember(Long currentUserId) { + ProjectMember member = findMemberByUserId(currentUserId); + member.delete(); + } + + // Member 조회 헬퍼 메소드 + private ProjectMember findMemberByUserId(Long userId) { + return this.projectMembers.stream() + .filter(member -> member.isSameUser(userId) && !member.getIsDeleted()) + .findFirst() + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_MEMBER)); + } + + public int getCurrentMemberCount() { + + return (int)this.projectMembers.stream() + .filter(member -> !member.getIsDeleted()) + .count(); + } + + // 권한 검증 헬퍼 메소드 + private void checkOwner(Long currentUserId) { + if (!this.ownerId.equals(currentUserId)) { + throw new CustomException(CustomErrorCode.ACCESS_DENIED); + } + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/enums/ProjectState.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/enums/ProjectState.java new file mode 100644 index 000000000..ef308d4a6 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/project/enums/ProjectState.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.project.domain.project.enums; + +public enum ProjectState { + PENDING, + IN_PROGRESS, + CLOSED +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectCreatedDomainEvent.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectCreatedDomainEvent.java new file mode 100644 index 000000000..c93b8eed2 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectCreatedDomainEvent.java @@ -0,0 +1,14 @@ +package com.example.surveyapi.project.domain.project.event; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProjectCreatedDomainEvent { + private Long projectId; + private Long ownerId; + private LocalDateTime periodEnd; +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectDeletedDomainEvent.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectDeletedDomainEvent.java new file mode 100644 index 000000000..b4e3b702a --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectDeletedDomainEvent.java @@ -0,0 +1,12 @@ +package com.example.surveyapi.project.domain.project.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProjectDeletedDomainEvent { + private final Long projectId; + private final String projectName; + private final Long deleterId; +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectManagerAddedDomainEvent.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectManagerAddedDomainEvent.java new file mode 100644 index 000000000..e69de29bb diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectMemberAddedDomainEvent.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectMemberAddedDomainEvent.java new file mode 100644 index 000000000..e69de29bb diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectStateChangedDomainEvent.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectStateChangedDomainEvent.java new file mode 100644 index 000000000..afc5a6695 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/project/event/ProjectStateChangedDomainEvent.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.project.domain.project.event; + +import com.example.surveyapi.project.domain.project.enums.ProjectState; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProjectStateChangedDomainEvent { + private final Long projectId; + private final ProjectState projectState; +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/repository/ProjectRepository.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/repository/ProjectRepository.java new file mode 100644 index 000000000..838b43790 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/project/repository/ProjectRepository.java @@ -0,0 +1,39 @@ +package com.example.surveyapi.project.domain.project.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; + +import com.example.surveyapi.project.domain.dto.ProjectManagerResult; +import com.example.surveyapi.project.domain.dto.ProjectMemberResult; +import com.example.surveyapi.project.domain.dto.ProjectSearchResult; +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.enums.ProjectState; + +public interface ProjectRepository { + + void save(Project project); + + void saveAll(List projects); + + boolean existsByNameAndIsDeletedFalse(String name); + + List findMyProjectsAsManager(Long currentUserId); + + List findMyProjectsAsMember(Long currentUserId); + + Slice searchProjectsNoOffset(String keyword, Long lastProjectId, Pageable pageable); + + Optional findByIdAndIsDeletedFalse(Long projectId); + + List findPendingProjectsToStart(LocalDateTime now); + + List findInProgressProjectsToClose(LocalDateTime now); + + void updateStateByIds(List projectIds, ProjectState newState); + + List findAllWithParticipantsByUserId(Long userId); +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/domain/project/vo/ProjectPeriod.java b/project-module/src/main/java/com/example/surveyapi/project/domain/project/vo/ProjectPeriod.java new file mode 100644 index 000000000..eedbab7bf --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/domain/project/vo/ProjectPeriod.java @@ -0,0 +1,35 @@ +package com.example.surveyapi.project.domain.project.vo; + +import java.time.LocalDateTime; + +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EqualsAndHashCode +public class ProjectPeriod { + + private LocalDateTime periodStart; + private LocalDateTime periodEnd; + + private ProjectPeriod(LocalDateTime periodStart, LocalDateTime periodEnd) { + if (periodEnd != null && periodStart.isAfter(periodEnd)) { + throw new CustomException(CustomErrorCode.START_DATE_AFTER_END_DATE); + } + this.periodStart = periodStart; + this.periodEnd = periodEnd; + } + + public static ProjectPeriod of(LocalDateTime periodStart, LocalDateTime periodEnd) { + return new ProjectPeriod(periodStart, periodEnd); + } +} + diff --git a/project-module/src/main/java/com/example/surveyapi/project/infra/event/ProjectConsumer.java b/project-module/src/main/java/com/example/surveyapi/project/infra/event/ProjectConsumer.java new file mode 100644 index 000000000..91a4a1493 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/infra/event/ProjectConsumer.java @@ -0,0 +1,26 @@ +package com.example.surveyapi.project.infra.event; + +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.project.application.event.ProjectEventListenerPort; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.user.UserWithdrawEvent; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +@RabbitListener(queues = RabbitConst.QUEUE_NAME_PROJECT) +public class ProjectConsumer { + + private final ProjectEventListenerPort projectEventListenerPort; + + @RabbitHandler + @Transactional + public void handleUserWithdrawEvent(UserWithdrawEvent event) { + projectEventListenerPort.handleUserWithdrawEvent(event.getUserId()); + } +} diff --git a/project-module/src/main/java/com/example/surveyapi/project/infra/event/ProjectEventPublisherImpl.java b/project-module/src/main/java/com/example/surveyapi/project/infra/event/ProjectEventPublisherImpl.java new file mode 100644 index 000000000..c87370fff --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/infra/event/ProjectEventPublisherImpl.java @@ -0,0 +1,42 @@ +package com.example.surveyapi.project.infra.event; + +import java.util.Map; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.project.application.event.ProjectEventPublisher; +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.project.ProjectEvent; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ProjectEventPublisherImpl implements ProjectEventPublisher { + + private final RabbitTemplate rabbitTemplate; + private Map routingKeyMap; + + @PostConstruct + public void initialize() { + routingKeyMap = Map.of( + EventCode.PROJECT_STATE_CHANGED, RabbitConst.ROUTING_KEY_PROJECT_STATE_CHANGED, + EventCode.PROJECT_DELETED, RabbitConst.ROUTING_KEY_PROJECT_DELETED, + EventCode.PROJECT_CREATED, RabbitConst.ROUTING_KEY_PROJECT_CREATED + ); + } + + @Override + public void convertAndSend(ProjectEvent event) { + String routingKey = routingKeyMap.get(event.getEventCode()); + if (routingKey == null) { + throw new CustomException(CustomErrorCode.NOT_FOUND_ROUTING_KEY); + } + rabbitTemplate.convertAndSend(RabbitConst.EXCHANGE_NAME, routingKey, event); + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/infra/repository/ProjectRepositoryImpl.java b/project-module/src/main/java/com/example/surveyapi/project/infra/repository/ProjectRepositoryImpl.java new file mode 100644 index 000000000..3edd8a0c4 --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/infra/repository/ProjectRepositoryImpl.java @@ -0,0 +1,83 @@ +package com.example.surveyapi.project.infra.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.project.domain.dto.ProjectManagerResult; +import com.example.surveyapi.project.domain.dto.ProjectMemberResult; +import com.example.surveyapi.project.domain.dto.ProjectSearchResult; +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.enums.ProjectState; +import com.example.surveyapi.project.domain.project.repository.ProjectRepository; +import com.example.surveyapi.project.infra.repository.jpa.ProjectJpaRepository; +import com.example.surveyapi.project.infra.repository.querydsl.ProjectQuerydslRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ProjectRepositoryImpl implements ProjectRepository { + + private final ProjectJpaRepository projectJpaRepository; + private final ProjectQuerydslRepository projectQuerydslRepository; + + @Override + public void save(Project project) { + projectJpaRepository.save(project); + } + + @Override + public void saveAll(List projects) { + projectJpaRepository.saveAll(projects); + } + + @Override + public boolean existsByNameAndIsDeletedFalse(String name) { + return projectJpaRepository.existsByNameAndIsDeletedFalse(name); + } + + @Override + public List findMyProjectsAsManager(Long currentUserId) { + return projectQuerydslRepository.findMyProjectsAsManager(currentUserId); + } + + @Override + public List findMyProjectsAsMember(Long currentUserId) { + return projectQuerydslRepository.findMyProjectsAsMember(currentUserId); + } + + @Override + public Slice searchProjectsNoOffset(String keyword, Long lastProjectId, Pageable pageable) { + return projectQuerydslRepository.searchProjectsNoOffset(keyword, lastProjectId, pageable); + } + + @Override + public Optional findByIdAndIsDeletedFalse(Long projectId) { + return projectQuerydslRepository.findByIdAndIsDeletedFalse(projectId); + } + + @Override + public List findPendingProjectsToStart(LocalDateTime now) { + return projectQuerydslRepository.findPendingProjectsToStart(now); + } + + @Override + public List findInProgressProjectsToClose(LocalDateTime now) { + return projectQuerydslRepository.findInProgressProjectsToClose(now); + } + + @Override + public void updateStateByIds(List projectIds, ProjectState newState) { + projectQuerydslRepository.updateStateByIds(projectIds, newState); + } + + @Override + public List findAllWithParticipantsByUserId(Long userId) { + return projectQuerydslRepository.findAllWithParticipantsByUserId(userId); + } +} \ No newline at end of file diff --git a/project-module/src/main/java/com/example/surveyapi/project/infra/repository/jpa/ProjectJpaRepository.java b/project-module/src/main/java/com/example/surveyapi/project/infra/repository/jpa/ProjectJpaRepository.java new file mode 100644 index 000000000..eb5dad29e --- /dev/null +++ b/project-module/src/main/java/com/example/surveyapi/project/infra/repository/jpa/ProjectJpaRepository.java @@ -0,0 +1,9 @@ +package com.example.surveyapi.project.infra.repository.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.surveyapi.project.domain.project.entity.Project; + +public interface ProjectJpaRepository extends JpaRepository { + boolean existsByNameAndIsDeletedFalse(String name); +} diff --git a/src/test/java/com/example/surveyapi/domain/project/api/ProjectControllerTest.java b/project-module/src/test/java/com/example/surveyapi/project/api/ProjectControllerTest.java similarity index 93% rename from src/test/java/com/example/surveyapi/domain/project/api/ProjectControllerTest.java rename to project-module/src/test/java/com/example/surveyapi/project/api/ProjectControllerTest.java index abb8d6785..71c91eb2d 100644 --- a/src/test/java/com/example/surveyapi/domain/project/api/ProjectControllerTest.java +++ b/project-module/src/test/java/com/example/surveyapi/project/api/ProjectControllerTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.project.api; +package com.example.surveyapi.project.api; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -31,19 +31,19 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; -import com.example.surveyapi.domain.project.application.ProjectQueryService; -import com.example.surveyapi.domain.project.application.ProjectService; -import com.example.surveyapi.domain.project.application.dto.request.CreateProjectRequest; -import com.example.surveyapi.domain.project.application.dto.request.UpdateManagerRoleRequest; -import com.example.surveyapi.domain.project.application.dto.request.UpdateProjectOwnerRequest; -import com.example.surveyapi.domain.project.application.dto.request.UpdateProjectRequest; -import com.example.surveyapi.domain.project.application.dto.request.UpdateProjectStateRequest; -import com.example.surveyapi.domain.project.application.dto.response.CreateProjectResponse; -import com.example.surveyapi.domain.project.application.dto.response.ProjectInfoResponse; -import com.example.surveyapi.domain.project.application.dto.response.ProjectMemberIdsResponse; -import com.example.surveyapi.domain.project.domain.participant.manager.enums.ManagerRole; -import com.example.surveyapi.domain.project.domain.project.entity.Project; -import com.example.surveyapi.domain.project.domain.project.enums.ProjectState; +import com.example.surveyapi.project.application.ProjectQueryService; +import com.example.surveyapi.project.application.ProjectService; +import com.example.surveyapi.project.application.dto.request.CreateProjectRequest; +import com.example.surveyapi.project.application.dto.request.UpdateManagerRoleRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectOwnerRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectStateRequest; +import com.example.surveyapi.project.application.dto.response.CreateProjectResponse; +import com.example.surveyapi.project.application.dto.response.ProjectInfoResponse; +import com.example.surveyapi.project.application.dto.response.ProjectMemberIdsResponse; +import com.example.surveyapi.project.domain.participant.manager.enums.ManagerRole; +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.enums.ProjectState; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/test/java/com/example/surveyapi/domain/project/application/ProjectServiceIntegrationTest.java b/project-module/src/test/java/com/example/surveyapi/project/application/ProjectServiceIntegrationTest.java similarity index 87% rename from src/test/java/com/example/surveyapi/domain/project/application/ProjectServiceIntegrationTest.java rename to project-module/src/test/java/com/example/surveyapi/project/application/ProjectServiceIntegrationTest.java index 230428479..33d4fcf03 100644 --- a/src/test/java/com/example/surveyapi/domain/project/application/ProjectServiceIntegrationTest.java +++ b/project-module/src/test/java/com/example/surveyapi/project/application/ProjectServiceIntegrationTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.project.application; +package com.example.surveyapi.project.application; import static org.assertj.core.api.Assertions.*; @@ -8,18 +8,18 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.util.ReflectionTestUtils; -import com.example.surveyapi.domain.project.application.dto.request.CreateProjectRequest; -import com.example.surveyapi.domain.project.application.dto.request.UpdateManagerRoleRequest; -import com.example.surveyapi.domain.project.application.dto.request.UpdateProjectOwnerRequest; -import com.example.surveyapi.domain.project.application.dto.request.UpdateProjectRequest; -import com.example.surveyapi.domain.project.application.dto.request.UpdateProjectStateRequest; -import com.example.surveyapi.domain.project.application.dto.response.ProjectMemberIdsResponse; -import com.example.surveyapi.domain.project.domain.participant.manager.entity.ProjectManager; -import com.example.surveyapi.domain.project.domain.participant.manager.enums.ManagerRole; -import com.example.surveyapi.domain.project.domain.project.entity.Project; -import com.example.surveyapi.domain.project.domain.project.enums.ProjectState; -import com.example.surveyapi.domain.project.infra.repository.jpa.ProjectJpaRepository; -import com.example.surveyapi.domain.survey.application.IntegrationTestBase; +import com.example.surveyapi.project.application.dto.request.CreateProjectRequest; +import com.example.surveyapi.project.application.dto.request.UpdateManagerRoleRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectOwnerRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectRequest; +import com.example.surveyapi.project.application.dto.request.UpdateProjectStateRequest; +import com.example.surveyapi.project.application.dto.response.ProjectMemberIdsResponse; +import com.example.surveyapi.project.domain.participant.manager.entity.ProjectManager; +import com.example.surveyapi.project.domain.participant.manager.enums.ManagerRole; +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.enums.ProjectState; +import com.example.surveyapi.project.infra.repository.jpa.ProjectJpaRepository; +import com.example.surveyapi.survey.application.IntegrationTestBase; /** * DB에 정상적으로 반영되는지 확인하기 위한 통합 테스트 diff --git a/src/test/java/com/example/surveyapi/domain/project/domain/manager/ProjectManagerTest.java b/project-module/src/test/java/com/example/surveyapi/project/domain/manager/ProjectManagerTest.java similarity index 93% rename from src/test/java/com/example/surveyapi/domain/project/domain/manager/ProjectManagerTest.java rename to project-module/src/test/java/com/example/surveyapi/project/domain/manager/ProjectManagerTest.java index 7ca424359..8e8503ab7 100644 --- a/src/test/java/com/example/surveyapi/domain/project/domain/manager/ProjectManagerTest.java +++ b/project-module/src/test/java/com/example/surveyapi/project/domain/manager/ProjectManagerTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.project.domain.manager; +package com.example.surveyapi.project.domain.manager; import static org.junit.jupiter.api.Assertions.*; @@ -7,9 +7,9 @@ import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; -import com.example.surveyapi.domain.project.domain.participant.manager.entity.ProjectManager; -import com.example.surveyapi.domain.project.domain.participant.manager.enums.ManagerRole; -import com.example.surveyapi.domain.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.participant.manager.entity.ProjectManager; +import com.example.surveyapi.project.domain.participant.manager.enums.ManagerRole; +import com.example.surveyapi.project.domain.project.entity.Project; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/src/test/java/com/example/surveyapi/domain/project/domain/member/ProjectMemberTest.java b/project-module/src/test/java/com/example/surveyapi/project/domain/member/ProjectMemberTest.java similarity index 92% rename from src/test/java/com/example/surveyapi/domain/project/domain/member/ProjectMemberTest.java rename to project-module/src/test/java/com/example/surveyapi/project/domain/member/ProjectMemberTest.java index edfe3f0f4..2910ef110 100644 --- a/src/test/java/com/example/surveyapi/domain/project/domain/member/ProjectMemberTest.java +++ b/project-module/src/test/java/com/example/surveyapi/project/domain/member/ProjectMemberTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.project.domain.member; +package com.example.surveyapi.project.domain.member; import static org.assertj.core.api.Assertions.*; @@ -6,7 +6,7 @@ import org.junit.jupiter.api.Test; -import com.example.surveyapi.domain.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.entity.Project; import com.example.surveyapi.global.exception.CustomException; public class ProjectMemberTest { diff --git a/src/test/java/com/example/surveyapi/domain/project/domain/project/ProjectTest.java b/project-module/src/test/java/com/example/surveyapi/project/domain/project/ProjectTest.java similarity index 91% rename from src/test/java/com/example/surveyapi/domain/project/domain/project/ProjectTest.java rename to project-module/src/test/java/com/example/surveyapi/project/domain/project/ProjectTest.java index bf85c56aa..3cf83a244 100644 --- a/src/test/java/com/example/surveyapi/domain/project/domain/project/ProjectTest.java +++ b/project-module/src/test/java/com/example/surveyapi/project/domain/project/ProjectTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.project.domain.project; +package com.example.surveyapi.project.domain.project; import static java.time.temporal.ChronoUnit.*; import static org.assertj.core.api.Assertions.*; @@ -8,10 +8,10 @@ import org.junit.jupiter.api.Test; -import com.example.surveyapi.domain.project.domain.participant.manager.entity.ProjectManager; -import com.example.surveyapi.domain.project.domain.participant.manager.enums.ManagerRole; -import com.example.surveyapi.domain.project.domain.project.entity.Project; -import com.example.surveyapi.domain.project.domain.project.enums.ProjectState; +import com.example.surveyapi.project.domain.participant.manager.entity.ProjectManager; +import com.example.surveyapi.project.domain.participant.manager.enums.ManagerRole; +import com.example.surveyapi.project.domain.project.entity.Project; +import com.example.surveyapi.project.domain.project.enums.ProjectState; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/settings.gradle b/settings.gradle index 710a7048b..3cc862c52 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,10 @@ rootProject.name = 'survey-api' + +include 'shared-kernel' +include 'user-module' +include 'project-module' +include 'survey-module' +include 'participation-module' +include 'statistic-module' +include 'share-module' +include 'web-app' \ No newline at end of file diff --git a/share-module/.dockerignore b/share-module/.dockerignore new file mode 100644 index 000000000..7080a6d13 --- /dev/null +++ b/share-module/.dockerignore @@ -0,0 +1,64 @@ +user-module/ +survey-module/ +project-module/ +participation-module/ +statistic-module/ +web-app/ + +build/ +target/ +.gradle/ +out/ +bin/ + +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo + +.DS_Store +Thumbs.db +desktop.ini + +.env +.env.* +properties.env +docker/ + +*.md +docs/ +images/ +*.png +*.jpg +*.jpeg +*.gif + +test-results/ +coverage/ +*test.properties +jacoco/ + +logs/ +*.log +*.log.* + +tmp/ +temp/ +*.tmp +*.temp + +grafana/ +prometheus/ +ecs-task-definitions/ +loadtest-package/ + +.git/ +.gitignore +.gitattributes + +.github/ +.gitlab-ci.yml +.travis.yml diff --git a/share-module/Dockerfile b/share-module/Dockerfile new file mode 100644 index 000000000..8c9212481 --- /dev/null +++ b/share-module/Dockerfile @@ -0,0 +1,44 @@ +FROM eclipse-temurin:17-jdk AS builder + +WORKDIR /app + +COPY gradle/ gradle/ +COPY gradlew . +COPY build.gradle . +COPY settings.gradle . + +COPY shared-kernel/build.gradle shared-kernel/ +COPY shared-kernel/src/ shared-kernel/src/ + +COPY share-module/build.gradle share-module/ +COPY share-module/src/ share-module/src/ + +RUN ./gradlew :share-module:bootJar --no-daemon + +FROM eclipse-temurin:17-jre-alpine AS runtime + +RUN apk add --no-cache curl + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder /app/share-module/build/libs/*.jar app.jar + +RUN chown appuser:appgroup app.jar + +USER appuser + +EXPOSE 8086 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8086/actuator/health || exit 1 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+ExitOnOutOfMemoryError", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] diff --git a/share-module/build.gradle b/share-module/build.gradle new file mode 100644 index 000000000..463fce55f --- /dev/null +++ b/share-module/build.gradle @@ -0,0 +1,24 @@ +jar { + enabled = true + archiveClassifier = '' +} + +bootJar { + enabled = false +} + +dependencies { + implementation project(':shared-kernel') + + runtimeOnly 'org.postgresql:postgresql' + + implementation 'org.springframework.boot:spring-boot-starter-mail' + + implementation 'com.google.firebase:firebase-admin:9.2.0' + + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + testImplementation 'org.springframework.security:spring-security-test' +} \ No newline at end of file diff --git a/share-module/docker-compose.yml b/share-module/docker-compose.yml new file mode 100644 index 000000000..893299a52 --- /dev/null +++ b/share-module/docker-compose.yml @@ -0,0 +1,53 @@ +version: '3.8' + +services: + share-service: + build: + context: .. + dockerfile: share-module/Dockerfile + ports: + - "8086:8086" + environment: + - SPRING_PROFILES_ACTIVE=dev + - SERVER_PORT=8086 + - MONGODB_HOST=mongodb + - MONGODB_PORT=${MONGODB_PORT:-27017} + - MONGODB_DATABASE=${MONGODB_DATABASE} + - MONGODB_USERNAME=${MONGODB_USERNAME} + - MONGODB_PASSWORD=${MONGODB_PASSWORD} + depends_on: + mongodb: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8086/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - share-network + + mongodb: + image: mongo:7 + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD} + MONGO_INITDB_DATABASE: ${MONGODB_DATABASE} + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.runCommand('ping').ok", "--quiet"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - share-network + +volumes: + mongodb_data: + +networks: + share-network: + driver: bridge diff --git a/share-module/src/main/java/com/example/surveyapi/share/api/ShareController.java b/share-module/src/main/java/com/example/surveyapi/share/api/ShareController.java new file mode 100644 index 000000000..4163c7fdf --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/api/ShareController.java @@ -0,0 +1,86 @@ +package com.example.surveyapi.share.api; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.share.application.notification.NotificationService; +import com.example.surveyapi.share.application.notification.dto.NotificationEmailCreateRequest; +import com.example.surveyapi.share.application.notification.dto.NotificationResponse; +import com.example.surveyapi.share.application.share.ShareService; +import com.example.surveyapi.share.application.share.dto.ShareResponse; +import com.example.surveyapi.global.dto.ApiResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping +public class ShareController { + private final ShareService shareService; + private final NotificationService notificationService; + + @PostMapping("/share-tasks/{shareId}/notifications") + public ResponseEntity> createNotifications( + @RequestHeader("Authorization") String authHeader, + @PathVariable Long shareId, + @Valid @RequestBody NotificationEmailCreateRequest request, + @AuthenticationPrincipal Long creatorId + ) { + shareService.createNotifications( + authHeader, shareId, + creatorId, request.getShareMethod(), + request.getEmails(), request.getNotifyAt()); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body(ApiResponse.success("알림 생성 성공", null)); + } + + @GetMapping("/share-tasks/{sourceType}/{sourceId}") + public ResponseEntity>> get( + @PathVariable String sourceType, + @PathVariable Long sourceId, + @AuthenticationPrincipal Long currentUserId + ) { + List responses = shareService.getShare(sourceType, sourceId, currentUserId); + + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiResponse.success("공유 작업 조회 성공", responses)); + } + + @GetMapping("/share-tasks/{shareId}/notifications") + public ResponseEntity>> getAll( + @PathVariable Long shareId, + @AuthenticationPrincipal Long currentId, + Pageable pageable + ) { + Page response = notificationService.gets(shareId, currentId, pageable); + + return ResponseEntity.ok(ApiResponse.success("알림 이력 조회 성공", response)); + } + + @GetMapping("/notifications") + public ResponseEntity>> getMyNotifications( + @AuthenticationPrincipal Long currentId, + Pageable pageable + ) { + Page response = notificationService.getMyNotifications(currentId, pageable); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("알림 조회 성공", response)); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/api/external/FcmController.java b/share-module/src/main/java/com/example/surveyapi/share/api/external/FcmController.java new file mode 100644 index 000000000..ccf001135 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/api/external/FcmController.java @@ -0,0 +1,31 @@ +package com.example.surveyapi.share.api.external; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.share.application.fcm.FcmTokenService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/fcm") +public class FcmController { + private final FcmTokenService tokenService; + + @PostMapping("/token") + public ResponseEntity save( + @RequestParam String token, + @AuthenticationPrincipal Long userId + ) { + tokenService.saveToken(userId, token); + + return ResponseEntity.status(HttpStatus.OK) + .build(); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/api/external/ShareExternalController.java b/share-module/src/main/java/com/example/surveyapi/share/api/external/ShareExternalController.java new file mode 100644 index 000000000..b37436939 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/api/external/ShareExternalController.java @@ -0,0 +1,58 @@ +package com.example.surveyapi.share.api.external; + +import java.net.URI; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.share.application.share.ShareService; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.global.dto.ApiResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/share") +public class ShareExternalController { + private final ShareService shareService; + + @GetMapping("{sourceType}/{sourceId}/link") + public ResponseEntity> getLink( + @PathVariable ShareSourceType sourceType, + @PathVariable Long sourceId) { + Share share = shareService.getShareBySource(sourceType, sourceId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("공유 링크 조회 성공", share.getLink())); + } + + @GetMapping("/surveys/{token}") + public ResponseEntity redirectToSurvey(@PathVariable String token) { + String redirectUrl = shareService.getRedirectUrl(token, ShareSourceType.SURVEY); + + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(redirectUrl)).build(); + } + + @GetMapping("/projects/members/{token}") + public ResponseEntity redirectToProjectMember(@PathVariable String token) { + String redirectUrl = shareService.getRedirectUrl(token, ShareSourceType.PROJECT_MEMBER); + + return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT) + .location(URI.create(redirectUrl)).build(); + } + + @GetMapping("/projects/managers/{token}") + public ResponseEntity redirectToProjectManager(@PathVariable String token) { + String redirectUrl = shareService.getRedirectUrl(token, ShareSourceType.PROJECT_MANAGER); + + return ResponseEntity.status(HttpStatus.TEMPORARY_REDIRECT) + .location(URI.create(redirectUrl)).build(); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/client/ShareValidationResponse.java b/share-module/src/main/java/com/example/surveyapi/share/application/client/ShareValidationResponse.java new file mode 100644 index 000000000..e9aeee1bd --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/client/ShareValidationResponse.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.share.application.client; + +public class ShareValidationResponse { + private final boolean valid; + + public ShareValidationResponse(boolean valid) { + this.valid = valid; + } + + public boolean isValid() { + return valid; + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/client/UserEmailDto.java b/share-module/src/main/java/com/example/surveyapi/share/application/client/UserEmailDto.java new file mode 100644 index 000000000..4de05331d --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/client/UserEmailDto.java @@ -0,0 +1,11 @@ +package com.example.surveyapi.share.application.client; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserEmailDto { + private Long userId; + private String email; +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/client/UserServicePort.java b/share-module/src/main/java/com/example/surveyapi/share/application/client/UserServicePort.java new file mode 100644 index 000000000..b8eff01c7 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/client/UserServicePort.java @@ -0,0 +1,6 @@ +package com.example.surveyapi.share.application.client; + +public interface UserServicePort { + + UserEmailDto getUserByEmail(String authHeader, String email); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/event/dto/ShareCreateRequest.java b/share-module/src/main/java/com/example/surveyapi/share/application/event/dto/ShareCreateRequest.java new file mode 100644 index 000000000..a5a32bd66 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/event/dto/ShareCreateRequest.java @@ -0,0 +1,14 @@ +package com.example.surveyapi.share.application.event.dto; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ShareCreateRequest { + private Long sourceId; + private Long creatorId; + private LocalDateTime expirationDate; +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/event/dto/ShareDeleteRequest.java b/share-module/src/main/java/com/example/surveyapi/share/application/event/dto/ShareDeleteRequest.java new file mode 100644 index 000000000..2e73d4681 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/event/dto/ShareDeleteRequest.java @@ -0,0 +1,11 @@ +package com.example.surveyapi.share.application.event.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ShareDeleteRequest { + private Long projectId; + private Long deleterId; +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/event/port/ShareEventHandler.java b/share-module/src/main/java/com/example/surveyapi/share/application/event/port/ShareEventHandler.java new file mode 100644 index 000000000..d65b3dd2f --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/event/port/ShareEventHandler.java @@ -0,0 +1,56 @@ +package com.example.surveyapi.share.application.event.port; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.share.application.event.dto.ShareCreateRequest; +import com.example.surveyapi.share.application.event.dto.ShareDeleteRequest; +import com.example.surveyapi.share.application.share.ShareService; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ShareEventHandler implements ShareEventPort { + private final ShareService shareService; + + @Override + public void handleSurveyEvent(ShareCreateRequest request) { + shareService.createShare( + ShareSourceType.SURVEY, + request.getSourceId(), + request.getCreatorId(), + request.getExpirationDate() + ); + } + + @Override + public void handleProjectCreateEvent(ShareCreateRequest request) { + shareService.createShare( + ShareSourceType.PROJECT_MANAGER, + request.getSourceId(), + request.getCreatorId(), + request.getExpirationDate() + ); + shareService.createShare( + ShareSourceType.PROJECT_MEMBER, + request.getSourceId(), + request.getCreatorId(), + request.getExpirationDate() + ); + } + + @Override + public void handleProjectDeleteEvent(ShareDeleteRequest request) { + List shares = shareService.getShareBySourceId(request.getProjectId()); + + for (Share share : shares) { + shareService.delete(share.getId(), request.getDeleterId()); + } + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/event/port/ShareEventPort.java b/share-module/src/main/java/com/example/surveyapi/share/application/event/port/ShareEventPort.java new file mode 100644 index 000000000..148a435af --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/event/port/ShareEventPort.java @@ -0,0 +1,12 @@ +package com.example.surveyapi.share.application.event.port; + +import com.example.surveyapi.share.application.event.dto.ShareCreateRequest; +import com.example.surveyapi.share.application.event.dto.ShareDeleteRequest; + +public interface ShareEventPort { + void handleSurveyEvent(ShareCreateRequest request); + + void handleProjectCreateEvent(ShareCreateRequest request); + + void handleProjectDeleteEvent(ShareDeleteRequest request); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/fcm/FcmTokenService.java b/share-module/src/main/java/com/example/surveyapi/share/application/fcm/FcmTokenService.java new file mode 100644 index 000000000..13a04353a --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/fcm/FcmTokenService.java @@ -0,0 +1,22 @@ +package com.example.surveyapi.share.application.fcm; + +import org.springframework.stereotype.Service; + +import com.example.surveyapi.share.domain.fcm.entity.FcmToken; +import com.example.surveyapi.share.domain.fcm.repository.FcmTokenRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class FcmTokenService { + private final FcmTokenRepository tokenRepository; + + public void saveToken(Long userId, String token) { + tokenRepository.findByUserId(userId) + .ifPresentOrElse( + existing -> existing.updateToken(token), + () -> tokenRepository.save(new FcmToken(userId, token)) + ); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationScheduler.java b/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationScheduler.java new file mode 100644 index 000000000..4cb799d0d --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationScheduler.java @@ -0,0 +1,33 @@ +package com.example.surveyapi.share.application.notification; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.repository.NotificationRepository; +import com.example.surveyapi.share.domain.notification.vo.Status; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationScheduler { + private final NotificationRepository notificationRepository; + private final NotificationService notificationService; + + @Scheduled(fixedDelay = 60000) + @Transactional + public void send() { + LocalDateTime now = LocalDateTime.now(); + + List toSend = notificationRepository.findBeforeSent( + Status.READY_TO_SEND, now + ); + + toSend.forEach(notificationService::send); + } +} \ No newline at end of file diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationSendService.java b/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationSendService.java new file mode 100644 index 000000000..2c4731c4f --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationSendService.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.share.application.notification; + +import com.example.surveyapi.share.domain.notification.entity.Notification; + +public interface NotificationSendService { + void send(Notification notification); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationService.java b/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationService.java new file mode 100644 index 000000000..772fb2754 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/notification/NotificationService.java @@ -0,0 +1,67 @@ +package com.example.surveyapi.share.application.notification; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.share.application.client.ShareValidationResponse; +import com.example.surveyapi.share.application.notification.dto.NotificationResponse; +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.repository.NotificationRepository; +import com.example.surveyapi.share.domain.notification.repository.query.NotificationQueryRepository; +import com.example.surveyapi.share.domain.notification.vo.Status; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + private final NotificationQueryRepository notificationQueryRepository; + private final NotificationRepository notificationRepository; + private final NotificationSendService notificationSendService; + + @Transactional + public void send(Notification notification) { + try { + notificationSendService.send(notification); + notification.setSent(); + } catch (Exception e) { + notification.setFailed(e.getMessage()); + } + + notificationRepository.save(notification); + } + + public Page gets( + Long shareId, + Long requesterId, + Pageable pageable + ) { + + Page notifications = notificationQueryRepository.findPageByShareId(shareId, requesterId, pageable); + + return notifications.map(NotificationResponse::from); + } + + public ShareValidationResponse isRecipient(Long sourceId, Long recipientId) { + boolean valid = notificationQueryRepository.isRecipient(sourceId, recipientId); + + return new ShareValidationResponse(valid); + } + + @Transactional + public Page getMyNotifications( + Long currentId, + Pageable pageable + ) { + Page notifications = notificationQueryRepository.findPageByUserId(currentId, pageable); + + notifications.stream() + .filter(n -> n.getStatus() == Status.SENT) + .forEach(Notification::setCheck); + + return notifications.map(NotificationResponse::from); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/notification/dto/NotificationEmailCreateRequest.java b/share-module/src/main/java/com/example/surveyapi/share/application/notification/dto/NotificationEmailCreateRequest.java new file mode 100644 index 000000000..cb168d915 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/notification/dto/NotificationEmailCreateRequest.java @@ -0,0 +1,20 @@ +package com.example.surveyapi.share.application.notification.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; + +import jakarta.validation.constraints.Email; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class NotificationEmailCreateRequest { + private ShareMethod shareMethod; + private List<@Email String> emails; + private LocalDateTime notifyAt; +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/notification/dto/NotificationResponse.java b/share-module/src/main/java/com/example/surveyapi/share/application/notification/dto/NotificationResponse.java new file mode 100644 index 000000000..d599b4a3d --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/notification/dto/NotificationResponse.java @@ -0,0 +1,31 @@ +package com.example.surveyapi.share.application.notification.dto; + +import java.time.LocalDateTime; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.vo.Status; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class NotificationResponse { + private Long id; + private Long recipientId; + private Status status; + private LocalDateTime sentAt; + private String failedReason; + + public NotificationResponse(Notification notification) { + this.id = notification.getId(); + this.recipientId = notification.getRecipientId(); + this.status = notification.getStatus(); + this.sentAt = notification.getSentAt(); + this.failedReason = notification.getFailedReason(); + } + + public static NotificationResponse from(Notification notification) { + return new NotificationResponse(notification); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/share/ShareService.java b/share-module/src/main/java/com/example/surveyapi/share/application/share/ShareService.java new file mode 100644 index 000000000..4349a012e --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/share/ShareService.java @@ -0,0 +1,185 @@ +package com.example.surveyapi.share.application.share; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.share.application.client.UserEmailDto; +import com.example.surveyapi.share.application.client.UserServicePort; +import com.example.surveyapi.share.application.share.dto.ShareResponse; +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; +import com.example.surveyapi.share.domain.share.ShareDomainService; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.repository.ShareRepository; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ShareService { + private final ShareRepository shareRepository; + private final ShareDomainService shareDomainService; + private final UserServicePort userServicePort; + + public ShareResponse createShare(ShareSourceType sourceType, Long sourceId, + Long creatorId, LocalDateTime expirationDate) { + Share existingShare = shareRepository.findBySource(sourceType, sourceId); + + if(existingShare != null) { + throw new CustomException(CustomErrorCode.ALREADY_EXISTED_SHARE); + } + + Share share = shareDomainService.createShare( + sourceType, sourceId, + creatorId, expirationDate); + Share saved = shareRepository.save(share); + + return ShareResponse.from(saved); + } + + public void createNotifications(String authHeader, Long shareId, Long creatorId, + ShareMethod shareMethod, List emails, + LocalDateTime notifyAt) { + Share share = shareRepository.findById(shareId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SHARE)); + + if (!share.isOwner(creatorId)) { + throw new CustomException(CustomErrorCode.ACCESS_DENIED_SHARE); + } + + Map emailToUserIdMap = new HashMap<>(); + + if ((shareMethod == ShareMethod.PUSH || shareMethod == ShareMethod.APP) + && emails != null && !emails.isEmpty()) { + for (String email : emails) { + try { + UserEmailDto userEmailDto = userServicePort.getUserByEmail(authHeader, email); + if (userEmailDto != null && userEmailDto.getUserId() != null) { + emailToUserIdMap.put(email, userEmailDto.getUserId()); + } + } catch (Exception e) { + throw new CustomException(CustomErrorCode.CANNOT_CREATE_NOTIFICATION); + } + } + } + + share.createNotifications(shareMethod, emails, notifyAt, emailToUserIdMap); + } + + @Transactional(readOnly = true) + public List getShare(String sourceType, Long sourceId, Long currentUserId) { + List shares; + + if ("project".equalsIgnoreCase(sourceType)) { + Share managerShare = shareRepository.findBySource(ShareSourceType.PROJECT_MANAGER, sourceId); + Share memberShare = shareRepository.findBySource(ShareSourceType.PROJECT_MEMBER, sourceId); + + shares = new ArrayList<>(); + if (managerShare != null) shares.add(managerShare); + if (memberShare != null) shares.add(memberShare); + } else if ("survey".equalsIgnoreCase(sourceType)) { + Share surveyShare = shareRepository.findBySource(ShareSourceType.SURVEY, sourceId); + + if (surveyShare != null) { + shares = List.of(surveyShare); + } else { + shares = Collections.emptyList(); + } + } else { + throw new CustomException(CustomErrorCode.INVALID_SHARE_TYPE); + } + + if (shares.isEmpty()) { + throw new CustomException(CustomErrorCode.NOT_FOUND_SHARE); + } + + shares.forEach(share -> { + if (!share.isOwner(currentUserId)) { + throw new CustomException(CustomErrorCode.NOT_FOUND_SHARE); + } + }); + + + return shares.stream().map(ShareResponse::from) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public Share getShareEntity(Long shareId, Long currentUserId) { + Share share = shareRepository.findById(shareId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SHARE)); + + if (!share.isOwner(currentUserId)) { + throw new CustomException(CustomErrorCode.NOT_FOUND_SHARE); + } + + return share; + } + + @Transactional(readOnly = true) + public Share getShareBySource(ShareSourceType sourceType, Long sourceId) { + Share share = shareRepository.findBySource(sourceType, sourceId); + + if (share.isDeleted()) { + throw new CustomException(CustomErrorCode.NOT_FOUND_SHARE); + } + + return share; + } + + @Transactional(readOnly = true) + public List getShareBySourceId(Long sourceId) { + List shares = shareRepository.findBySourceId(sourceId); + + if (shares.isEmpty()) { + throw new CustomException(CustomErrorCode.NOT_FOUND_SHARE); + } + + return shares; + } + + public String delete(Long shareId, Long currentUserId) { + Share share = shareRepository.findById(shareId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SHARE)); + + if (!share.isOwner(currentUserId)) { + throw new CustomException(CustomErrorCode.NOT_FOUND_SHARE); + } + shareRepository.delete(share); + + return "공유 삭제 완료"; + } + + @Transactional(readOnly = true) + public Share getShareByToken(String token) { + Share share = shareRepository.findByToken(token) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SHARE)); + + if(share.isDeleted() || share.getExpirationDate().isBefore(LocalDateTime.now())) { + throw new CustomException(CustomErrorCode.SHARE_EXPIRED); + } + + return share; + } + + public String getRedirectUrl(String token, ShareSourceType sourceType) { + Share share = getShareByToken(token); + + if (share.getSourceType() != sourceType) { + throw new CustomException(CustomErrorCode.INVALID_SHARE_TYPE); + } + + return shareDomainService.getRedirectUrl(share); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/application/share/dto/ShareResponse.java b/share-module/src/main/java/com/example/surveyapi/share/application/share/dto/ShareResponse.java new file mode 100644 index 000000000..b5a482a6d --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/application/share/dto/ShareResponse.java @@ -0,0 +1,24 @@ +package com.example.surveyapi.share.application.share.dto; + +import java.time.LocalDateTime; + +import com.example.surveyapi.share.domain.share.entity.Share; + +import lombok.Getter; + +@Getter +public class ShareResponse { + private final String shareLink; + private final LocalDateTime expirationDate; + private final LocalDateTime createdAt; + + private ShareResponse(Share share) { + this.shareLink = share.getLink(); + this.expirationDate = share.getExpirationDate(); + this.createdAt = share.getCreatedAt(); + } + + public static ShareResponse from(Share share) { + return new ShareResponse(share); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/fcm/entity/FcmToken.java b/share-module/src/main/java/com/example/surveyapi/share/domain/fcm/entity/FcmToken.java new file mode 100644 index 000000000..d255c67eb --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/fcm/entity/FcmToken.java @@ -0,0 +1,29 @@ +package com.example.surveyapi.share.domain.fcm.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; + +@Entity +@Table(name = "fcm_token") +@Getter +public class FcmToken { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long userId; + private String token; + + public FcmToken(Long userId, String token) { + this.userId = userId; + this.token = token; + } + + public void updateToken(String newToken) { + this.token = newToken; + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/fcm/repository/FcmTokenRepository.java b/share-module/src/main/java/com/example/surveyapi/share/domain/fcm/repository/FcmTokenRepository.java new file mode 100644 index 000000000..eec4c6a39 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/fcm/repository/FcmTokenRepository.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.share.domain.fcm.repository; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.share.domain.fcm.entity.FcmToken; + +@Repository +public interface FcmTokenRepository { + FcmToken save(FcmToken token); + Optional findByUserId(Long userId); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/notification/entity/Notification.java b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/entity/Notification.java new file mode 100644 index 000000000..d94fd634a --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/entity/Notification.java @@ -0,0 +1,90 @@ +package com.example.surveyapi.share.domain.notification.entity; + +import java.time.LocalDateTime; + +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; +import com.example.surveyapi.share.domain.notification.vo.Status; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.global.model.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "notifications") +public class Notification extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "share_id") + private Share share; + @Enumerated(EnumType.STRING) + @Column(name = "share_method") + private ShareMethod shareMethod; + @Column(name = "recipient_id") + private Long recipientId; + @Column(name = "recipient_email") + private String recipientEmail; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private Status status; + @Column(name = "sent_at") + private LocalDateTime sentAt; + @Column(name = "failed_reason") + private String failedReason; + @Column(name = "notify_at") + private LocalDateTime notifyAt; + + public Notification( + Share share, + ShareMethod shareMethod, + Long recipientId, + String recipientEmail, + Status status, + LocalDateTime sentAt, + String failedReason, + LocalDateTime notifyAt + ) { + this.share = share; + this.shareMethod = shareMethod; + this.recipientId = recipientId; + this.recipientEmail = recipientEmail; + this.status = status; + this.sentAt = sentAt; + this.failedReason = failedReason; + this.notifyAt = notifyAt; + } + + public static Notification createForShare(Share share, ShareMethod shareMethod, Long recipientId, String recipientEmail, LocalDateTime notifyAt) { + return new Notification(share, shareMethod, recipientId, recipientEmail, Status.READY_TO_SEND, null, null, notifyAt); + } + + public void setSent() { + this.status = Status.SENT; + this.sentAt = LocalDateTime.now(); + } + + public void setFailed(String failedReason) { + this.status = Status.FAILED; + this.failedReason = failedReason; + } + + public void setCheck() { + this.status = Status.CHECK; + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/notification/repository/NotificationRepository.java b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/repository/NotificationRepository.java new file mode 100644 index 000000000..634b380f6 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,23 @@ +package com.example.surveyapi.share.domain.notification.repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.vo.Status; + +public interface NotificationRepository { + Page findByShareId(Long shareId, Pageable pageable); + + void saveAll(List notifications); + + List findBeforeSent(Status status, LocalDateTime notifyAt); + + void save(Notification notification); + + Optional findById(Long id); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/notification/repository/query/NotificationQueryRepository.java b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/repository/query/NotificationQueryRepository.java new file mode 100644 index 000000000..3554be75f --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/repository/query/NotificationQueryRepository.java @@ -0,0 +1,14 @@ +package com.example.surveyapi.share.domain.notification.repository.query; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.example.surveyapi.share.domain.notification.entity.Notification; + +public interface NotificationQueryRepository { + Page findPageByShareId(Long shareId, Long requesterId, Pageable pageable); + + boolean isRecipient(Long sourceId, Long recipientId); + + Page findPageByUserId(Long userId, Pageable pageable); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/notification/vo/ShareMethod.java b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/vo/ShareMethod.java new file mode 100644 index 000000000..fd05b9441 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/vo/ShareMethod.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.share.domain.notification.vo; + +public enum ShareMethod { + EMAIL, + URL, + PUSH, + APP +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/notification/vo/Status.java b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/vo/Status.java new file mode 100644 index 000000000..3997fa547 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/notification/vo/Status.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.share.domain.notification.vo; + +public enum Status { + READY_TO_SEND, + SENT, + FAILED, + CHECK +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/share/ShareDomainService.java b/share-module/src/main/java/com/example/surveyapi/share/domain/share/ShareDomainService.java new file mode 100644 index 000000000..7cda6b89f --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/share/ShareDomainService.java @@ -0,0 +1,51 @@ +package com.example.surveyapi.share.domain.share; + +import java.time.LocalDateTime; +import java.util.UUID; + +import org.springframework.stereotype.Service; + +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +@Service +public class ShareDomainService { + private static final String SURVEY_URL = "http://localhost:8080/share/surveys/"; + private static final String PROJECT_MEMBER_URL = "http://localhost:8080/share/projects/members/"; + private static final String PROJECT_MANAGER_URL = "http://localhost:8080/share/projects/managers/"; + + public Share createShare(ShareSourceType sourceType, Long sourceId, + Long creatorId, LocalDateTime expirationDate) { + String token = UUID.randomUUID().toString().replace("-", ""); + String link = generateLink(sourceType, token); + + return new Share(sourceType, sourceId, + creatorId, token, + link, expirationDate); + } + + public String generateLink(ShareSourceType sourceType, String token) { + + if (sourceType == ShareSourceType.SURVEY) { + return SURVEY_URL + token; + } else if (sourceType == ShareSourceType.PROJECT_MEMBER) { + return PROJECT_MEMBER_URL + token; + } else if (sourceType == ShareSourceType.PROJECT_MANAGER) { + return PROJECT_MANAGER_URL + token; + } + throw new CustomException(CustomErrorCode.UNSUPPORTED_SHARE_METHOD); + } + + public String getRedirectUrl(Share share) { + if (share.getSourceType() == ShareSourceType.PROJECT_MEMBER) { + return "http://localhost:8080/api/projects/" + share.getSourceId() + "/members/join"; + } else if (share.getSourceType() == ShareSourceType.PROJECT_MANAGER) { + return "http://localhost:8080/api/projects/" + share.getSourceId() + "/managers"; + } else if (share.getSourceType() == ShareSourceType.SURVEY) { + return "http://localhost:8080/api/surveys/" + share.getSourceId(); + } + throw new CustomException(CustomErrorCode.INVALID_SHARE_TYPE); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/share/entity/Share.java b/share-module/src/main/java/com/example/surveyapi/share/domain/share/entity/Share.java new file mode 100644 index 000000000..773e4cc83 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/share/entity/Share.java @@ -0,0 +1,107 @@ +package com.example.surveyapi.share.domain.share.entity; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.global.model.BaseEntity; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "share") +public class Share extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + @Enumerated(EnumType.STRING) + @Column(name = "source_type", nullable = false) + private ShareSourceType sourceType; + @Column(name = "source_id", nullable = false) + private Long sourceId; + @Column(name = "creator_id", nullable = false) + private Long creatorId; + @Column(name = "token", nullable = false) + private String token; + @Column(name = "link", nullable = false, unique = true) + private String link; + @Column(name = "expiration", nullable = false) + private LocalDateTime expirationDate; + + @OneToMany(mappedBy = "share", cascade = CascadeType.ALL, orphanRemoval = true) + private List notifications = new ArrayList<>(); + + public Share(ShareSourceType sourceType, Long sourceId, + Long creatorId, String token, + String link, LocalDateTime expirationDate) { + this.sourceType = sourceType; + this.sourceId = sourceId; + this.creatorId = creatorId; + this.token = token; + this.link = link; + this.expirationDate = expirationDate; + + } + + public boolean isAlreadyExist(String link) { + boolean isExist = this.link.equals(link); + return isExist; + } + + public boolean isOwner(Long currentUserId) { + if (creatorId.equals(currentUserId)) { + return true; + } + return false; + } + + public void createNotifications(ShareMethod shareMethod, List emails, + LocalDateTime notifyAt, Map emailToUserIdMap) { + if(shareMethod == ShareMethod.URL) { + return; + } + + if(emails == null || emails.isEmpty()) { + notifications.add( + Notification.createForShare( + this, shareMethod, + this.creatorId, null, + notifyAt) + ); + + return; + } + + emails.forEach(email -> { + Long recipientId = emailToUserIdMap.get(email); + notifications.add( + Notification.createForShare( + this, shareMethod, + recipientId, email, + notifyAt) + ); + }); + } + + public boolean isDeleted() { + return isDeleted; + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/share/repository/ShareRepository.java b/share-module/src/main/java/com/example/surveyapi/share/domain/share/repository/ShareRepository.java new file mode 100644 index 000000000..7eb6dc864 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/share/repository/ShareRepository.java @@ -0,0 +1,22 @@ +package com.example.surveyapi.share.domain.share.repository; + +import java.util.List; +import java.util.Optional; + +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; + +public interface ShareRepository { + Optional findByLink(String link); + Share save(Share share); + + Optional findById(Long id); + + Optional findByToken(String token); + + void delete(Share share); + + Share findBySource(ShareSourceType sourceType, Long sourceId); + + List findBySourceId(Long sourceId); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/share/repository/query/ShareQueryRepository.java b/share-module/src/main/java/com/example/surveyapi/share/domain/share/repository/query/ShareQueryRepository.java new file mode 100644 index 000000000..be03a1e63 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/share/repository/query/ShareQueryRepository.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.share.domain.share.repository.query; + +public interface ShareQueryRepository { + boolean isExist(Long surveyId, Long userId); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/domain/share/vo/ShareSourceType.java b/share-module/src/main/java/com/example/surveyapi/share/domain/share/vo/ShareSourceType.java new file mode 100644 index 000000000..5f04357da --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/domain/share/vo/ShareSourceType.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.share.domain.share.vo; + +public enum ShareSourceType { + PROJECT_MEMBER, + PROJECT_MANAGER, + SURVEY +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/adapter/UserServiceShareAdapter.java b/share-module/src/main/java/com/example/surveyapi/share/infra/adapter/UserServiceShareAdapter.java new file mode 100644 index 000000000..7c520114a --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/adapter/UserServiceShareAdapter.java @@ -0,0 +1,27 @@ +package com.example.surveyapi.share.infra.adapter; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.share.application.client.UserEmailDto; +import com.example.surveyapi.share.application.client.UserServicePort; +import com.example.surveyapi.global.client.UserApiClient; +import com.example.surveyapi.global.dto.ExternalApiResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class UserServiceShareAdapter implements UserServicePort { + private final UserApiClient userApiClient; + private final ObjectMapper objectMapper; + + @Override + public UserEmailDto getUserByEmail(String authHeader, String email) { + ExternalApiResponse userResponse = userApiClient.getUserByEmail(authHeader, email); + Object rawData = userResponse.getOrThrow(); + + return objectMapper.convertValue(rawData, new TypeReference() {}); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/event/ShareConsumer.java b/share-module/src/main/java/com/example/surveyapi/share/infra/event/ShareConsumer.java new file mode 100644 index 000000000..19a4ab74b --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/event/ShareConsumer.java @@ -0,0 +1,76 @@ +package com.example.surveyapi.share.infra.event; + +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.share.application.event.dto.ShareCreateRequest; +import com.example.surveyapi.share.application.event.dto.ShareDeleteRequest; +import com.example.surveyapi.share.application.event.port.ShareEventPort; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.project.ProjectCreatedEvent; +import com.example.surveyapi.global.event.project.ProjectDeletedEvent; +import com.example.surveyapi.global.event.survey.SurveyActivateEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@RabbitListener( + queues = RabbitConst.QUEUE_NAME_SHARE +) +public class ShareConsumer { + private final ShareEventPort shareEventPort; + + @RabbitHandler + public void handleSurveyEvent(SurveyActivateEvent event) { + try { + log.info("Received survey event"); + + ShareCreateRequest request = new ShareCreateRequest( + event.getSurveyId(), + event.getCreatorId(), + event.getEndTime() + ); + + shareEventPort.handleSurveyEvent(request); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + @RabbitHandler + public void handleProjectDeleteEvent(ProjectDeletedEvent event) { + try { + log.info("Received project delete event"); + + ShareDeleteRequest request = new ShareDeleteRequest( + event.getProjectId(), + event.getDeleterId() + ); + + shareEventPort.handleProjectDeleteEvent(request); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + @RabbitHandler + public void handleProjectCreatedEvent(ProjectCreatedEvent event) { + try { + log.info("Received project create event"); + + ShareCreateRequest request = new ShareCreateRequest( + event.getProjectId(), + event.getOwnerId(), + event.getPeriodEnd() + ); + + shareEventPort.handleProjectCreateEvent(request); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/fcm/FcmTokenRepositoryImpl.java b/share-module/src/main/java/com/example/surveyapi/share/infra/fcm/FcmTokenRepositoryImpl.java new file mode 100644 index 000000000..3246c1bd5 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/fcm/FcmTokenRepositoryImpl.java @@ -0,0 +1,26 @@ +package com.example.surveyapi.share.infra.fcm; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.share.domain.fcm.entity.FcmToken; +import com.example.surveyapi.share.domain.fcm.repository.FcmTokenRepository; +import com.example.surveyapi.share.infra.fcm.jpa.FcmTokenJpaRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FcmTokenRepositoryImpl implements FcmTokenRepository { + private final FcmTokenJpaRepository fcmTokenJpaRepository; + + @Override + public FcmToken save(FcmToken token) { + return fcmTokenJpaRepository.save(token); + } + @Override + public Optional findByUserId(Long userId) { + return fcmTokenJpaRepository.findByUserId(userId); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/fcm/jpa/FcmTokenJpaRepository.java b/share-module/src/main/java/com/example/surveyapi/share/infra/fcm/jpa/FcmTokenJpaRepository.java new file mode 100644 index 000000000..fe52a2800 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/fcm/jpa/FcmTokenJpaRepository.java @@ -0,0 +1,11 @@ +package com.example.surveyapi.share.infra.fcm.jpa; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.surveyapi.share.domain.fcm.entity.FcmToken; + +public interface FcmTokenJpaRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/NotificationRepositoryImpl.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/NotificationRepositoryImpl.java new file mode 100644 index 000000000..88c373a3d --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/NotificationRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.example.surveyapi.share.infra.notification; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.repository.NotificationRepository; +import com.example.surveyapi.share.domain.notification.vo.Status; +import com.example.surveyapi.share.infra.notification.jpa.NotificationJpaRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class NotificationRepositoryImpl implements NotificationRepository { + private final NotificationJpaRepository notificationJpaRepository; + + @Override + public Page findByShareId(Long shareId, Pageable pageable) { + return notificationJpaRepository.findByShareId(shareId, pageable); + } + + @Override + public void saveAll(List notifications) { + notificationJpaRepository.saveAll(notifications); + } + + @Override + public List findBeforeSent(Status status, LocalDateTime notifyAt) { + return notificationJpaRepository.findByStatusAndNotifyAtLessThanEqual(status, notifyAt); + } + + @Override + public void save(Notification notification) { + notificationJpaRepository.save(notification); + } + + @Override + public Optional findById(Long id) { + return notificationJpaRepository.findById(id); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/dsl/NotificationQueryDslRepository.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/dsl/NotificationQueryDslRepository.java new file mode 100644 index 000000000..7cabaf3be --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/dsl/NotificationQueryDslRepository.java @@ -0,0 +1,14 @@ +package com.example.surveyapi.share.infra.notification.dsl; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.example.surveyapi.share.domain.notification.entity.Notification; + +public interface NotificationQueryDslRepository { + Page findByShareId(Long shareId, Long requesterId, Pageable pageable); + + boolean isRecipient(Long sourceId, Long recipientId); + + Page findByUserId(Long userId, Pageable pageable); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/dsl/NotificationQueryDslRepositoryImpl.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/dsl/NotificationQueryDslRepositoryImpl.java new file mode 100644 index 000000000..fa0992dce --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/dsl/NotificationQueryDslRepositoryImpl.java @@ -0,0 +1,103 @@ +package com.example.surveyapi.share.infra.notification.dsl; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.entity.QNotification; +import com.example.surveyapi.share.domain.notification.vo.Status; +import com.example.surveyapi.share.domain.share.entity.QShare; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class NotificationQueryDslRepositoryImpl implements NotificationQueryDslRepository { + private final JPAQueryFactory queryFactory; + + @Override + public Page findByShareId(Long shareId, Long requesterId, Pageable pageable) { + QNotification notification = QNotification.notification; + QShare share = QShare.share; + + Share foundShare = queryFactory + .selectFrom(share) + .where(share.id.eq(shareId)) + .fetchOne(); + + if(foundShare == null) { + throw new CustomException(CustomErrorCode.NOT_FOUND_SHARE); + } + + if(!foundShare.getCreatorId().equals(requesterId)) { + throw new CustomException(CustomErrorCode.ACCESS_DENIED_SHARE); + } + + List content = queryFactory + .selectFrom(notification) + .where(notification.share.id.eq(shareId)) + .orderBy(notification.sentAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + long total = queryFactory + .select(notification.count()) + .from(notification) + .where(notification.share.id.eq(shareId)) + .fetchOne(); + + Page pageResult = new PageImpl<>(content, pageable, Optional.ofNullable(total).orElse(0L)); + + return pageResult; + } + + @Override + public boolean isRecipient(Long sourceId, Long recipientId) { + QNotification notification = QNotification.notification; + QShare share = QShare.share; + + Long count = queryFactory + .select(notification.count()) + .from(notification) + .join(notification.share, share) + .where( + share.sourceId.eq(sourceId), + notification.recipientId.eq(recipientId) + ).fetchOne(); + + return count != null && count > 0; + } + + @Override + public Page findByUserId(Long userId, Pageable pageable) { + QNotification notification = QNotification.notification; + + List content = queryFactory + .selectFrom(notification) + .where(notification.recipientId.eq(userId), notification.status.eq(Status.SENT)) + .orderBy(notification.sentAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + long total = queryFactory + .select(notification.count()) + .from(notification) + .where(notification.recipientId.eq(userId), notification.status.eq(Status.SENT)) + .fetchOne(); + + Page pageResult = new PageImpl<>(content, pageable, Optional.ofNullable(total).orElse(0L)); + + return pageResult; + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/jpa/NotificationJpaRepository.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/jpa/NotificationJpaRepository.java new file mode 100644 index 000000000..e8b07b130 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/jpa/NotificationJpaRepository.java @@ -0,0 +1,17 @@ +package com.example.surveyapi.share.infra.notification.jpa; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.vo.Status; + +public interface NotificationJpaRepository extends JpaRepository { + Page findByShareId(Long shareId, Pageable pageable); + + List findByStatusAndNotifyAtLessThanEqual(Status status, LocalDateTime notifyAt); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/query/NotificationQueryRepositoryImpl.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/query/NotificationQueryRepositoryImpl.java new file mode 100644 index 000000000..39b21f263 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/query/NotificationQueryRepositoryImpl.java @@ -0,0 +1,35 @@ +package com.example.surveyapi.share.infra.notification.query; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.repository.query.NotificationQueryRepository; +import com.example.surveyapi.share.infra.notification.dsl.NotificationQueryDslRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class NotificationQueryRepositoryImpl implements NotificationQueryRepository { + private final NotificationQueryDslRepository dslRepository; + + @Override + public Page findPageByShareId(Long shareId, Long requesterId, Pageable pageable) { + + return dslRepository.findByShareId(shareId, requesterId, pageable); + } + + @Override + public boolean isRecipient(Long sourceId, Long recipientId) { + + return dslRepository.isRecipient(sourceId, recipientId); + } + + @Override + public Page findPageByUserId(Long userId, Pageable pageable) { + + return dslRepository.findByUserId(userId, pageable); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationAppSender.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationAppSender.java new file mode 100644 index 000000000..ab7403abb --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationAppSender.java @@ -0,0 +1,18 @@ +package com.example.surveyapi.share.infra.notification.sender; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.share.domain.notification.entity.Notification; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component("APP") +@RequiredArgsConstructor +public class NotificationAppSender implements NotificationSender { + @Override + public void send(Notification notification) { + log.info("APP notification is created."); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationEmailSender.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationEmailSender.java new file mode 100644 index 000000000..0a58e96b8 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationEmailSender.java @@ -0,0 +1,54 @@ +package com.example.surveyapi.share.infra.notification.sender; + +import java.util.EnumMap; +import java.util.Map; + +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component("EMAIL") +@RequiredArgsConstructor +public class NotificationEmailSender implements NotificationSender { + private final JavaMailSender mailSender; + + private static final Map emailContentMap; + + static { + emailContentMap = new EnumMap<>(ShareSourceType.class); + emailContentMap.put(ShareSourceType.PROJECT_MANAGER, new EmailContent( + "회원님께서 프로젝트 관리자로 등록되었습니다.", "Link : ")); + emailContentMap.put(ShareSourceType.PROJECT_MEMBER, new EmailContent( + "회원님게서 프로젝트 대상자로 등록되었습니다.", "Link : ")); + emailContentMap.put(ShareSourceType.SURVEY, new EmailContent( + "회원님께서 설문 대상자로 등록되었습니다.", "지금 설문에 참여해보세요!\nLink : ")); + } + + private record EmailContent(String subject, String text) {} + + @Override + public void send(Notification notification) { + log.info("이메일 전송: {}", notification.getId()); + ShareSourceType sourceType = notification.getShare().getSourceType(); + EmailContent content = emailContentMap.getOrDefault(sourceType, null); + + if(content == null) { + log.error("알 수 없는 ShareSourceType: {}", sourceType); + return; + } + + SimpleMailMessage message = new SimpleMailMessage(); + message.setTo(notification.getRecipientEmail()); + message.setSubject(content.subject()); + message.setText(content.text() + notification.getShare().getLink()); + + mailSender.send(message); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationFactory.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationFactory.java new file mode 100644 index 000000000..fe876edd7 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationFactory.java @@ -0,0 +1,26 @@ +package com.example.surveyapi.share.infra.notification.sender; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class NotificationFactory { + private final Map senderMap; + + public NotificationSender getSender(ShareMethod method) { + NotificationSender sender = senderMap.get(method.name()); + if (sender == null) { + throw new CustomException(CustomErrorCode.UNSUPPORTED_SHARE_METHOD); + } + + return sender; + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationPushSender.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationPushSender.java new file mode 100644 index 000000000..fad67199f --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationPushSender.java @@ -0,0 +1,80 @@ +package com.example.surveyapi.share.infra.notification.sender; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Optional; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.share.domain.fcm.entity.FcmToken; +import com.example.surveyapi.share.domain.fcm.repository.FcmTokenRepository; +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.Message; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component("PUSH") +@RequiredArgsConstructor +public class NotificationPushSender implements NotificationSender { + private final FcmTokenRepository tokenRepository; + private final FirebaseMessaging firebaseMessaging; + + private static final Map pushContentMap; + + static { + pushContentMap = new EnumMap<>(ShareSourceType.class); + pushContentMap.put(ShareSourceType.PROJECT_MANAGER, new PushContent( + "회원님께서 프로젝트 관리자로 등록되었습니다.", "회원님께서 프로젝트 관리자로 등록되었습니다.")); + pushContentMap.put(ShareSourceType.PROJECT_MEMBER, new PushContent( + "회원님게서 프로젝트 대상자로 등록되었습니다.", "회원님께서 프로젝트 대상자로 등록되었습니다.")); + pushContentMap.put(ShareSourceType.SURVEY, new PushContent( + "회원님께서 설문 대상자로 등록되었습니다.", "회원님께서 설문 대상자로 등록되었습니다. 지금 설문에 참여해보세요!")); + } + + private record PushContent(String title, String body) { + } + + @Override + public void send(Notification notification) { + + Long userId = notification.getRecipientId(); + Optional fcmToken = tokenRepository.findByUserId(userId); + + if (fcmToken.isEmpty()) { + log.info("userId: {} - 토큰이 존재하지 않습니다.", userId); + return; + } + + String token = fcmToken.get().getToken(); + + ShareSourceType sourceType = notification.getShare().getSourceType(); + PushContent content = pushContentMap.getOrDefault(sourceType, null); + + if (content == null) { + log.error("알 수 없는 ShareSourceType: {}", sourceType); + return; + } + + Message message = Message.builder() + .setToken(token) + .putData("title", content.title()) + .putData("body", content.body() + "\n" + notification.getShare().getLink()) + .build(); + + try { + String response = firebaseMessaging.send(message); + log.info("userId: {}, notificationId: {}, response: {} - PUSH 알림 발송", userId, notification.getId(), + response); + } catch (FirebaseMessagingException e) { + log.error("userId: {}, notificationId: {} - PUSH 전송 실패", userId, notification.getId()); + throw new CustomException(CustomErrorCode.PUSH_FAILED); + } + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationSendServiceImpl.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationSendServiceImpl.java new file mode 100644 index 000000000..ceef3fedb --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationSendServiceImpl.java @@ -0,0 +1,20 @@ +package com.example.surveyapi.share.infra.notification.sender; + +import org.springframework.stereotype.Service; + +import com.example.surveyapi.share.application.notification.NotificationSendService; +import com.example.surveyapi.share.domain.notification.entity.Notification; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class NotificationSendServiceImpl implements NotificationSendService { + private final NotificationFactory factory; + + @Override + public void send(Notification notification) { + NotificationSender sender = factory.getSender(notification.getShareMethod()); + sender.send(notification); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationSender.java b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationSender.java new file mode 100644 index 000000000..0579b6cd3 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/notification/sender/NotificationSender.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.share.infra.notification.sender; + +import com.example.surveyapi.share.domain.notification.entity.Notification; + +public interface NotificationSender { + void send(Notification notification); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/share/ShareRepositoryImpl.java b/share-module/src/main/java/com/example/surveyapi/share/infra/share/ShareRepositoryImpl.java new file mode 100644 index 000000000..eff6765d6 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/share/ShareRepositoryImpl.java @@ -0,0 +1,54 @@ +package com.example.surveyapi.share.infra.share; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.repository.ShareRepository; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.share.infra.share.jpa.ShareJpaRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ShareRepositoryImpl implements ShareRepository { + private final ShareJpaRepository shareJpaRepository; + + @Override + public Optional findByLink(String link) { + return shareJpaRepository.findByLink(link); + } + + @Override + public Share save(Share share) { + return shareJpaRepository.save(share); + } + + @Override + public Optional findById(Long id) { + return shareJpaRepository.findById(id); + } + + @Override + public Optional findByToken(String token) { + return shareJpaRepository.findByToken(token); + } + + @Override + public void delete(Share share) { + shareJpaRepository.delete(share); + } + + @Override + public Share findBySource(ShareSourceType sourceType, Long sourceId) { + return shareJpaRepository.findBySourceTypeAndSourceId(sourceType, sourceId); + } + + @Override + public List findBySourceId(Long sourceId) { + return shareJpaRepository.findBySourceId(sourceId); + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/share/dsl/ShareQueryDslRepository.java b/share-module/src/main/java/com/example/surveyapi/share/infra/share/dsl/ShareQueryDslRepository.java new file mode 100644 index 000000000..ddf048726 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/share/dsl/ShareQueryDslRepository.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.share.infra.share.dsl; + +public interface ShareQueryDslRepository { + boolean isExist(Long surveyId, Long userId); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/share/dsl/ShareQueryDslRepositoryImpl.java b/share-module/src/main/java/com/example/surveyapi/share/infra/share/dsl/ShareQueryDslRepositoryImpl.java new file mode 100644 index 000000000..04ab2127e --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/share/dsl/ShareQueryDslRepositoryImpl.java @@ -0,0 +1,33 @@ +package com.example.surveyapi.share.infra.share.dsl; + +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.share.domain.notification.entity.QNotification; +import com.example.surveyapi.share.domain.share.entity.QShare; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ShareQueryDslRepositoryImpl implements ShareQueryDslRepository { + private final JPAQueryFactory queryFactory; + + @Override + public boolean isExist(Long sourceId, Long userId) { + QShare share = QShare.share; + QNotification notification = QNotification.notification; + + Integer fetchOne = queryFactory + .selectOne() + .from(share) + .join(share.notifications, notification) + .where( + share.sourceId.eq(sourceId), + notification.recipientId.eq(userId) + ) + .fetchFirst(); + + return fetchOne != null; + } +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/share/jpa/ShareJpaRepository.java b/share-module/src/main/java/com/example/surveyapi/share/infra/share/jpa/ShareJpaRepository.java new file mode 100644 index 000000000..322d43021 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/share/jpa/ShareJpaRepository.java @@ -0,0 +1,21 @@ +package com.example.surveyapi.share.infra.share.jpa; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; + +public interface ShareJpaRepository extends JpaRepository { + Optional findByLink(String link); + + Optional findById(Long id); + + Optional findByToken(String token); + + Share findBySourceTypeAndSourceId(ShareSourceType sourceType, Long sourceId); + + List findBySourceId(Long sourceId); +} diff --git a/share-module/src/main/java/com/example/surveyapi/share/infra/share/query/ShareQueryRepositoryImpl.java b/share-module/src/main/java/com/example/surveyapi/share/infra/share/query/ShareQueryRepositoryImpl.java new file mode 100644 index 000000000..5241d29c7 --- /dev/null +++ b/share-module/src/main/java/com/example/surveyapi/share/infra/share/query/ShareQueryRepositoryImpl.java @@ -0,0 +1,20 @@ +package com.example.surveyapi.share.infra.share.query; + +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.share.domain.share.repository.query.ShareQueryRepository; +import com.example.surveyapi.share.infra.share.dsl.ShareQueryDslRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ShareQueryRepositoryImpl implements ShareQueryRepository { + private final ShareQueryDslRepository dslRepository; + + @Override + public boolean isExist(Long surveyId, Long userId) { + + return dslRepository.isExist(surveyId, userId); + } +} diff --git a/src/test/java/com/example/surveyapi/domain/share/api/ShareControllerTest.java b/share-module/src/test/java/com/example/surveyapi/share/api/ShareControllerTest.java similarity index 91% rename from src/test/java/com/example/surveyapi/domain/share/api/ShareControllerTest.java rename to share-module/src/test/java/com/example/surveyapi/share/api/ShareControllerTest.java index f742091a2..54e5bc722 100644 --- a/src/test/java/com/example/surveyapi/domain/share/api/ShareControllerTest.java +++ b/share-module/src/test/java/com/example/surveyapi/share/api/ShareControllerTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.share.api; +package com.example.surveyapi.share.api; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.given; @@ -23,10 +23,10 @@ import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; -import com.example.surveyapi.domain.share.application.notification.NotificationService; -import com.example.surveyapi.domain.share.application.notification.dto.NotificationResponse; -import com.example.surveyapi.domain.share.application.share.ShareService; -import com.example.surveyapi.domain.share.domain.notification.vo.Status; +import com.example.surveyapi.share.application.notification.NotificationService; +import com.example.surveyapi.share.application.notification.dto.NotificationResponse; +import com.example.surveyapi.share.application.share.ShareService; +import com.example.surveyapi.share.domain.notification.vo.Status; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/src/test/java/com/example/surveyapi/domain/share/application/MailSendTest.java b/share-module/src/test/java/com/example/surveyapi/share/application/MailSendTest.java similarity index 67% rename from src/test/java/com/example/surveyapi/domain/share/application/MailSendTest.java rename to share-module/src/test/java/com/example/surveyapi/share/application/MailSendTest.java index c05213008..7f878280b 100644 --- a/src/test/java/com/example/surveyapi/domain/share/application/MailSendTest.java +++ b/share-module/src/test/java/com/example/surveyapi/share/application/MailSendTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.share.application; +package com.example.surveyapi.share.application; import static org.assertj.core.api.Assertions.*; @@ -12,16 +12,16 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; -import com.example.surveyapi.domain.share.application.notification.NotificationService; -import com.example.surveyapi.domain.share.application.share.ShareService; -import com.example.surveyapi.domain.share.application.share.dto.ShareResponse; -import com.example.surveyapi.domain.share.domain.notification.entity.Notification; -import com.example.surveyapi.domain.share.domain.notification.repository.NotificationRepository; -import com.example.surveyapi.domain.share.domain.notification.vo.Status; -import com.example.surveyapi.domain.share.domain.share.entity.Share; -import com.example.surveyapi.domain.share.domain.share.repository.ShareRepository; -import com.example.surveyapi.domain.share.domain.notification.vo.ShareMethod; -import com.example.surveyapi.domain.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.share.application.notification.NotificationService; +import com.example.surveyapi.share.application.share.ShareService; +import com.example.surveyapi.share.application.share.dto.ShareResponse; +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.repository.NotificationRepository; +import com.example.surveyapi.share.domain.notification.vo.Status; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.repository.ShareRepository; +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/src/test/java/com/example/surveyapi/domain/share/application/NotificationServiceTest.java b/share-module/src/test/java/com/example/surveyapi/share/application/NotificationServiceTest.java similarity index 87% rename from src/test/java/com/example/surveyapi/domain/share/application/NotificationServiceTest.java rename to share-module/src/test/java/com/example/surveyapi/share/application/NotificationServiceTest.java index b143ff838..e84b56969 100644 --- a/src/test/java/com/example/surveyapi/domain/share/application/NotificationServiceTest.java +++ b/share-module/src/test/java/com/example/surveyapi/share/application/NotificationServiceTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.share.application; +package com.example.surveyapi.share.application; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.given; @@ -19,16 +19,16 @@ import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; -import com.example.surveyapi.domain.share.application.client.ShareValidationResponse; -import com.example.surveyapi.domain.share.application.notification.NotificationSendService; -import com.example.surveyapi.domain.share.application.notification.NotificationService; -import com.example.surveyapi.domain.share.application.notification.dto.NotificationResponse; -import com.example.surveyapi.domain.share.domain.notification.entity.Notification; -import com.example.surveyapi.domain.share.domain.notification.repository.NotificationRepository; -import com.example.surveyapi.domain.share.domain.notification.repository.query.NotificationQueryRepository; -import com.example.surveyapi.domain.share.domain.notification.vo.Status; -import com.example.surveyapi.domain.share.domain.share.entity.Share; -import com.example.surveyapi.domain.share.domain.notification.vo.ShareMethod; +import com.example.surveyapi.share.application.client.ShareValidationResponse; +import com.example.surveyapi.share.application.notification.NotificationSendService; +import com.example.surveyapi.share.application.notification.NotificationService; +import com.example.surveyapi.share.application.notification.dto.NotificationResponse; +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.repository.NotificationRepository; +import com.example.surveyapi.share.domain.notification.repository.query.NotificationQueryRepository; +import com.example.surveyapi.share.domain.notification.vo.Status; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/src/test/java/com/example/surveyapi/domain/share/application/PushSendTest.java b/share-module/src/test/java/com/example/surveyapi/share/application/PushSendTest.java similarity index 70% rename from src/test/java/com/example/surveyapi/domain/share/application/PushSendTest.java rename to share-module/src/test/java/com/example/surveyapi/share/application/PushSendTest.java index 26a99a6bd..e3a2d6045 100644 --- a/src/test/java/com/example/surveyapi/domain/share/application/PushSendTest.java +++ b/share-module/src/test/java/com/example/surveyapi/share/application/PushSendTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.share.application; +package com.example.surveyapi.share.application; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -15,17 +15,17 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.transaction.annotation.Transactional; -import com.example.surveyapi.domain.share.application.notification.NotificationService; -import com.example.surveyapi.domain.share.application.share.ShareService; -import com.example.surveyapi.domain.share.application.share.dto.ShareResponse; -import com.example.surveyapi.domain.share.domain.fcm.entity.FcmToken; -import com.example.surveyapi.domain.share.domain.fcm.repository.FcmTokenRepository; -import com.example.surveyapi.domain.share.domain.notification.entity.Notification; -import com.example.surveyapi.domain.share.domain.notification.repository.NotificationRepository; -import com.example.surveyapi.domain.share.domain.notification.vo.Status; -import com.example.surveyapi.domain.share.domain.share.entity.Share; -import com.example.surveyapi.domain.share.domain.notification.vo.ShareMethod; -import com.example.surveyapi.domain.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.share.application.notification.NotificationService; +import com.example.surveyapi.share.application.share.ShareService; +import com.example.surveyapi.share.application.share.dto.ShareResponse; +import com.example.surveyapi.share.domain.fcm.entity.FcmToken; +import com.example.surveyapi.share.domain.fcm.repository.FcmTokenRepository; +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.repository.NotificationRepository; +import com.example.surveyapi.share.domain.notification.vo.Status; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; diff --git a/src/test/java/com/example/surveyapi/domain/share/application/ShareServiceTest.java b/share-module/src/test/java/com/example/surveyapi/share/application/ShareServiceTest.java similarity index 89% rename from src/test/java/com/example/surveyapi/domain/share/application/ShareServiceTest.java rename to share-module/src/test/java/com/example/surveyapi/share/application/ShareServiceTest.java index 2e82b70bc..f0105e46e 100644 --- a/src/test/java/com/example/surveyapi/domain/share/application/ShareServiceTest.java +++ b/share-module/src/test/java/com/example/surveyapi/share/application/ShareServiceTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.share.application; +package com.example.surveyapi.share.application; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -17,17 +17,17 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.transaction.annotation.Transactional; -import com.example.surveyapi.domain.share.application.client.UserEmailDto; -import com.example.surveyapi.domain.share.application.client.UserServicePort; -import com.example.surveyapi.domain.share.application.share.ShareService; -import com.example.surveyapi.domain.share.application.share.dto.ShareResponse; -import com.example.surveyapi.domain.share.domain.notification.entity.Notification; -import com.example.surveyapi.domain.share.domain.notification.repository.NotificationRepository; -import com.example.surveyapi.domain.share.domain.notification.vo.Status; -import com.example.surveyapi.domain.share.domain.share.entity.Share; -import com.example.surveyapi.domain.share.domain.share.repository.ShareRepository; -import com.example.surveyapi.domain.share.domain.notification.vo.ShareMethod; -import com.example.surveyapi.domain.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.share.application.client.UserEmailDto; +import com.example.surveyapi.share.application.client.UserServicePort; +import com.example.surveyapi.share.application.share.ShareService; +import com.example.surveyapi.share.application.share.dto.ShareResponse; +import com.example.surveyapi.share.domain.notification.entity.Notification; +import com.example.surveyapi.share.domain.notification.repository.NotificationRepository; +import com.example.surveyapi.share.domain.notification.vo.Status; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.repository.ShareRepository; +import com.example.surveyapi.share.domain.notification.vo.ShareMethod; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/src/test/java/com/example/surveyapi/domain/share/domain/ShareDomainServiceTest.java b/share-module/src/test/java/com/example/surveyapi/share/domain/ShareDomainServiceTest.java similarity index 93% rename from src/test/java/com/example/surveyapi/domain/share/domain/ShareDomainServiceTest.java rename to share-module/src/test/java/com/example/surveyapi/share/domain/ShareDomainServiceTest.java index d924ad082..6e2059492 100644 --- a/src/test/java/com/example/surveyapi/domain/share/domain/ShareDomainServiceTest.java +++ b/share-module/src/test/java/com/example/surveyapi/share/domain/ShareDomainServiceTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.share.domain; +package com.example.surveyapi.share.domain; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -6,9 +6,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import com.example.surveyapi.domain.share.domain.share.ShareDomainService; -import com.example.surveyapi.domain.share.domain.share.entity.Share; -import com.example.surveyapi.domain.share.domain.share.vo.ShareSourceType; +import com.example.surveyapi.share.domain.share.ShareDomainService; +import com.example.surveyapi.share.domain.share.entity.Share; +import com.example.surveyapi.share.domain.share.vo.ShareSourceType; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/shared-kernel/build.gradle b/shared-kernel/build.gradle new file mode 100644 index 000000000..0a031ec79 --- /dev/null +++ b/shared-kernel/build.gradle @@ -0,0 +1,49 @@ +jar { + enabled = true + archiveClassifier = '' +} + +bootJar { + enabled = false +} + +dependencies { + api 'org.springframework.boot:spring-boot-starter' + api 'org.springframework.boot:spring-boot-starter-validation' + api 'org.springframework.boot:spring-boot-starter-web' + api 'org.springframework.boot:spring-boot-starter-security' + api 'org.springframework.boot:spring-boot-starter-data-jpa' + + api 'io.jsonwebtoken:jjwt-api:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + api 'at.favre.lib:bcrypt:0.10.2' + + api 'org.springframework.boot:spring-boot-starter-data-redis' + + api 'org.springframework.boot:spring-boot-starter-amqp' + + api 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + api 'org.apache.httpcomponents.client5:httpclient5:5.2.1' + + api 'com.google.firebase:firebase-admin:9.2.0' + + api 'co.elastic.clients:elasticsearch-java:8.11.0' + api 'org.springframework.data:spring-data-elasticsearch:5.1.10' + + api 'org.springframework.boot:spring-boot-starter-mail' + + api 'org.springframework.boot:spring-boot-starter-actuator' + api 'io.micrometer:micrometer-registry-prometheus' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:postgresql' + testImplementation 'org.testcontainers:mongodb' + testImplementation 'org.springframework.test:spring-test' +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtAccessDeniedHandler.java b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 000000000..cd267ee8b --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,34 @@ +package com.example.surveyapi.global.auth.jwt; + +import java.io.IOException; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.global.dto.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + + log.warn("Access denied : {}", accessDeniedException.getMessage()); + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=utf-8"); + + ApiResponse apiResponse = ApiResponse.error("접근이 거부되었습니다.", "Access Denied"); + + ObjectMapper mapper = new ObjectMapper(); + response.getWriter().write(mapper.writeValueAsString(apiResponse)); + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtAuthenticationEntryPoint.java b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 000000000..f6e56a5ce --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,38 @@ +package com.example.surveyapi.global.auth.jwt; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.global.dto.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper mapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + + log.warn("Authentication failed : {}", authException.getMessage()); + + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json;charset=utf-8"); + + ApiResponse apiResponse = ApiResponse.error("인증에 실패했습니다", "Authentication Failed"); + + response.getWriter().write(mapper.writeValueAsString(apiResponse)); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtFilter.java b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtFilter.java new file mode 100644 index 000000000..15b8ef133 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtFilter.java @@ -0,0 +1,72 @@ +package com.example.surveyapi.global.auth.jwt; + +import java.io.IOException; +import java.util.List; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final RedisTemplate redisTemplate; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String token = resolveToken(request); + + String blackListToken = "blackListToken" + token; + if(Boolean.TRUE.equals(redisTemplate.hasKey(blackListToken))){ + request.setAttribute("exceptionMessage", "로그인 후 이용해주세요"); + throw new InsufficientAuthenticationException("로그인 후 이용해주세요"); + } + + try{ + if (token != null && jwtUtil.validateToken(token)) { + Claims claims = jwtUtil.extractClaims(token); + + Long userId = Long.parseLong(claims.getSubject()); + String userRole = claims.get("userRole", String.class); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + userId, null, List.of(new SimpleGrantedAuthority("ROLE_" + userRole))); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + }catch (JwtException e) { + log.error("Jwt validation failed {}", e.getMessage()); + SecurityContextHolder.clearContext(); + }catch (Exception e){ + log.error("Authentication error", e); + SecurityContextHolder.clearContext(); + } + + filterChain.doFilter(request, response); + } + + private String resolveToken (HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtUtil.java b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtUtil.java new file mode 100644 index 000000000..84b30e184 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/JwtUtil.java @@ -0,0 +1,127 @@ +package com.example.surveyapi.global.auth.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SecurityException; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +public class JwtUtil { + + private final SecretKey secretKey; + + public JwtUtil(@Value("${jwt.secret.key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + private static final String BEARER_PREFIX = "Bearer "; + private static final long TOKEN_TIME = 60 * 60 * 1000L; + private static final long REFRESH_TIME = 7 * 24 * 60 * 60 * 1000L; + + public String createAccessToken(Long userId, String userRole) { + Date date = new Date(); + + return BEARER_PREFIX + + Jwts.builder() + .subject(String.valueOf(userId)) + .claim("userRole", userRole) + .claim("type", "access") + .expiration(new Date(date.getTime() + TOKEN_TIME)) + .issuedAt(date) + .signWith(secretKey) + .compact(); + } + + public String createRefreshToken(Long userId, String userRole) { + Date date = new Date(); + + return BEARER_PREFIX + + Jwts.builder() + .subject(String.valueOf(userId)) + .claim("userRole", userRole) + .claim("type", "refresh") + .expiration(new Date(date.getTime() + REFRESH_TIME)) + .issuedAt(date) + .signWith(secretKey) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + log.warn("Invalid JWT token: {}", e.getMessage()); + throw new JwtException("잘못된 형식의 토큰입니다"); + } catch (ExpiredJwtException e) { + log.warn("Expired JWT token: {}", e.getMessage()); + throw new JwtException("만료된 토큰입니다"); + } catch (UnsupportedJwtException e) { + log.warn("Unsupported JWT token: {}", e.getMessage()); + throw new JwtException("지원하지 않는 토큰입니다"); + } catch (IllegalArgumentException e) { + log.warn("JWT claims string is empty: {}", e.getMessage()); + throw new JwtException("토큰 정보가 비어있습니다"); + } + } + + public boolean isTokenExpired(String token) { + try { + Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + return false; // 만료 안됨 + } catch (ExpiredJwtException e) { + return true; // 만료됨 + } catch (Exception e) { + throw new JwtException("유효하지 않은 토큰입니다."); + } + } + + + public String subStringToken(String token) { + if (StringUtils.hasText(token) && (token.startsWith(BEARER_PREFIX))) { + return token.substring(7); + } + throw new CustomException(CustomErrorCode.NOT_FOUND_TOKEN); + } + + public Claims extractClaims(String token) { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public Long getExpiration(String token) { + Date expiration = extractClaims(token).getExpiration(); + return expiration.getTime() - System.currentTimeMillis(); + } + + + +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/PasswordEncoder.java b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/PasswordEncoder.java new file mode 100644 index 000000000..6cac9aeff --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/jwt/PasswordEncoder.java @@ -0,0 +1,18 @@ +package com.example.surveyapi.global.auth.jwt; + +import org.springframework.stereotype.Component; + +import at.favre.lib.crypto.bcrypt.BCrypt; + +@Component +public class PasswordEncoder { + public String encode(String rawPassword){ + return BCrypt.withDefaults().hashToString(BCrypt.MIN_COST, rawPassword.toCharArray()); + } + + public boolean matches(String rawPassword, String encodedPassword) { + BCrypt.Result result = BCrypt.verifyer().verify(rawPassword.toCharArray(), encodedPassword); + return result.verified; + } + +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/GoogleOAuthProperties.java b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/GoogleOAuthProperties.java new file mode 100644 index 000000000..335e5f024 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/GoogleOAuthProperties.java @@ -0,0 +1,18 @@ +package com.example.surveyapi.global.auth.oauth; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +@Component +@ConfigurationProperties(prefix = "oauth.google") +public class GoogleOAuthProperties { + + private String clientId; + private String clientSecret; + private String redirectUri; +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/KakaoOAuthProperties.java b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/KakaoOAuthProperties.java new file mode 100644 index 000000000..256dde5d5 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/KakaoOAuthProperties.java @@ -0,0 +1,23 @@ +package com.example.surveyapi.global.auth.oauth; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +/** + * application.yml에 입력된 설정 값을 자바 객체에 매핑하기 위한 클래스 + * @ConfigurationProperties 사용하기 위해서 @Setter, @Getter 사용 + */ +@Setter +@Getter +@Component +@ConfigurationProperties(prefix = "oauth.kakao") +public class KakaoOAuthProperties { + + private String clientId; + private String redirectUri; + + +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/NaverOAuthProperties.java b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/NaverOAuthProperties.java new file mode 100644 index 000000000..bcd3b7f49 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/auth/oauth/NaverOAuthProperties.java @@ -0,0 +1,23 @@ +package com.example.surveyapi.global.auth.oauth; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +/** + * application.yml에 입력된 설정 값을 자바 객체에 매핑하기 위한 클래스 + * @ConfigurationProperties 사용하기 위해서 @Setter, @Getter 사용 + */ +@Setter +@Getter +@Component +@ConfigurationProperties(prefix = "oauth.naver") +public class NaverOAuthProperties { + + private String clientId; + private String clientSecret; + private String redirectUri; + +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/client/OAuthApiClient.java b/shared-kernel/src/main/java/com/example/surveyapi/global/client/OAuthApiClient.java new file mode 100644 index 000000000..997877ae4 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/client/OAuthApiClient.java @@ -0,0 +1,58 @@ +package com.example.surveyapi.global.client; + +import java.util.Map; + +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; +import org.springframework.web.service.annotation.PostExchange; + +@HttpExchange +public interface OAuthApiClient { + + @PostExchange( + url = "https://kauth.kakao.com/oauth/token", + contentType = "application/x-www-form-urlencoded;charset=utf-8") + Map getKakaoAccessToken( + @RequestParam("grant_type") String grant_type, + @RequestParam("client_id") String client_id, + @RequestParam("redirect_uri") String redirect_uri, + @RequestParam("code") String code + ); + + @GetExchange(url = "https://kapi.kakao.com/v2/user/me") + Map getKakaoUserInfo( + @RequestHeader("Authorization") String accessToken); + + @PostExchange( + url = "https://nid.naver.com/oauth2.0/token", + contentType = "application/x-www-form-urlencoded;charset=utf-8") + Map getNaverAccessToken( + @RequestParam("grant_type") String grant_type, + @RequestParam("client_id") String client_id, + @RequestParam("client_secret") String client_secret, + @RequestParam("code") String code, + @RequestParam("state") String state + ); + + @GetExchange(url = "https://openapi.naver.com/v1/nid/me") + Map getNaverUserInfo( + @RequestHeader("Authorization") String accessToken); + + @PostExchange( + url = "https://oauth2.googleapis.com/token", + contentType = "application/x-www-form-urlencoded;charset=utf-8") + Map getGoogleAccessToken( + @RequestParam("grant_type") String grant_type, + @RequestParam("client_id") String client_id, + @RequestParam("client_secret") String client_secret, + @RequestParam("redirect_uri") String redirect_uri, + @RequestParam("code") String code + ); + + @GetExchange(url = "https://openidconnect.googleapis.com/v1/userinfo") + Map getGoogleUserInfo( + @RequestHeader("Authorization") String accessToken); + +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/client/ParticipationApiClient.java b/shared-kernel/src/main/java/com/example/surveyapi/global/client/ParticipationApiClient.java new file mode 100644 index 000000000..a5361a622 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/client/ParticipationApiClient.java @@ -0,0 +1,25 @@ +package com.example.surveyapi.global.client; + +import java.util.List; + +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +import com.example.surveyapi.global.dto.ExternalApiResponse; + +@HttpExchange +public interface ParticipationApiClient { + + @GetExchange("/api/surveys/participations") + ExternalApiResponse getParticipationInfos( + @RequestHeader("Authorization") String authHeader, + @RequestParam List surveyIds + ); + + @GetExchange("/api/surveys/participations/count") + ExternalApiResponse getParticipationCounts( + @RequestParam List surveyIds + ); +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/client/ProjectApiClient.java b/shared-kernel/src/main/java/com/example/surveyapi/global/client/ProjectApiClient.java new file mode 100644 index 000000000..55bf56dcd --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/client/ProjectApiClient.java @@ -0,0 +1,23 @@ +package com.example.surveyapi.global.client; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +import com.example.surveyapi.global.dto.ExternalApiResponse; + +@HttpExchange +public interface ProjectApiClient { + + @GetExchange("/api/projects/me/managers") + ExternalApiResponse getProjectMembers( + @RequestHeader("Authorization") String authHeader + ); + + @GetExchange("/api/projects/{projectId}") + ExternalApiResponse getProjectState( + @RequestHeader("Authorization") String authHeader, + @PathVariable Long projectId + ); +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/client/ShareApiClient.java b/shared-kernel/src/main/java/com/example/surveyapi/global/client/ShareApiClient.java new file mode 100644 index 000000000..8976f7d0c --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/client/ShareApiClient.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.global.client; + +import org.springframework.web.service.annotation.HttpExchange; + +@HttpExchange +public interface ShareApiClient { +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/client/StatisticApiClient.java b/shared-kernel/src/main/java/com/example/surveyapi/global/client/StatisticApiClient.java new file mode 100644 index 000000000..f9a664525 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/client/StatisticApiClient.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.global.client; + +import org.springframework.web.service.annotation.HttpExchange; + +@HttpExchange +public interface StatisticApiClient { + +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/client/SurveyApiClient.java b/shared-kernel/src/main/java/com/example/surveyapi/global/client/SurveyApiClient.java new file mode 100644 index 000000000..3231fc3dc --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/client/SurveyApiClient.java @@ -0,0 +1,27 @@ +package com.example.surveyapi.global.client; + +import java.util.List; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +import com.example.surveyapi.global.dto.ExternalApiResponse; + +@HttpExchange +public interface SurveyApiClient { + + @GetExchange("/api/surveys/{surveyId}") + ExternalApiResponse getSurveyDetail( + @RequestHeader("Authorization") String authHeader, + @PathVariable Long surveyId + ); + + @GetExchange("/api/surveys/find-surveys") + ExternalApiResponse getSurveyInfoList( + @RequestHeader("Authorization") String authHeader, + @RequestParam List surveyIds + ); +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/client/UserApiClient.java b/shared-kernel/src/main/java/com/example/surveyapi/global/client/UserApiClient.java new file mode 100644 index 000000000..9905d50f7 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/client/UserApiClient.java @@ -0,0 +1,25 @@ +package com.example.surveyapi.global.client; + +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.service.annotation.GetExchange; +import org.springframework.web.service.annotation.HttpExchange; + +import com.example.surveyapi.global.dto.ExternalApiResponse; + +@HttpExchange +public interface UserApiClient { + + @GetExchange("/api/users/{userId}/snapshot") + ExternalApiResponse getParticipantInfo( + @RequestHeader("Authorization") String authHeader, + @PathVariable Long userId + ); + + @GetExchange("/users/by-email") + ExternalApiResponse getUserByEmail( + @RequestHeader("Authorization") String authHeader, + @RequestParam("email") String email + ); +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/AsyncConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/AsyncConfig.java new file mode 100644 index 000000000..98e174175 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/AsyncConfig.java @@ -0,0 +1,22 @@ +package com.example.surveyapi.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + @Bean(name = "externalAPI") + public TaskExecutor taskExecutor(){ + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); // 코어 스레드 개수 + executor.setMaxPoolSize(10); // 최대 스레드 개수 + executor.setQueueCapacity(100); // 작업 대기 큐 개수 + executor.setThreadNamePrefix("ExternalAPI-"); + executor.initialize(); + return executor; + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/ElasticsearchConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/ElasticsearchConfig.java new file mode 100644 index 000000000..d658d4630 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/ElasticsearchConfig.java @@ -0,0 +1,27 @@ +package com.example.surveyapi.global.config; + +import org.elasticsearch.client.RestClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.json.jackson.JacksonJsonpMapper; +import co.elastic.clients.transport.ElasticsearchTransport; +import co.elastic.clients.transport.rest_client.RestClientTransport; + +@Configuration +public class ElasticsearchConfig { + + @Bean + public ElasticsearchTransport elasticsearchTransport(RestClient restClient, ObjectMapper objectMapper) { + return new RestClientTransport(restClient, new JacksonJsonpMapper(objectMapper)); + } + + @Bean + public ElasticsearchClient elasticsearchClient(ElasticsearchTransport transport) { + return new ElasticsearchClient(transport); + } + +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/FcmConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/FcmConfig.java new file mode 100644 index 000000000..ae5edb135 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/FcmConfig.java @@ -0,0 +1,160 @@ +package com.example.surveyapi.global.config; + +import java.io.ByteArrayInputStream; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StringUtils; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; + +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class FcmConfig { + + // 기존 파일 경로 방식 (로컬 개발용) + @Value("${firebase.credentials.path:}") + private String firebaseCredentialsPath; + + // 환경변수 방식 (프로덕션용) + @Value("${firebase.project-id:survey-f5a93}") + private String projectId; + + @Value("${firebase.private-key-id:}") + private String privateKeyId; + + @Value("${firebase.private-key:}") + private String privateKey; + + @Value("${firebase.client-email:firebase-adminsdk-fbsvc@survey-f5a93.iam.gserviceaccount.com}") + private String clientEmail; + + @Value("${firebase.client-id:100191250643521230154}") + private String clientId; + + @Value("${firebase.enabled:true}") + private boolean firebaseEnabled; + + @PostConstruct + public void init() { + if (!firebaseEnabled) { + log.info("Firebase is disabled by configuration"); + return; + } + + if (StringUtils.hasText(firebaseCredentialsPath)) { + log.info("Firebase will be initialized using file: {}", firebaseCredentialsPath); + } else if (StringUtils.hasText(privateKey) && StringUtils.hasText(privateKeyId)) { + log.info("Firebase will be initialized using environment variables"); + } else { + log.warn("Firebase credentials not found. Firebase features will be disabled."); + } + } + + @Bean + public FirebaseApp firebaseApp() throws IOException { + if (!firebaseEnabled) { + log.warn("Firebase is disabled. Skipping FirebaseApp initialization."); + return null; + } + + InputStream credentialsStream = getCredentialsStream(); + + if (credentialsStream == null) { + log.error("Failed to get Firebase credentials. Firebase features will be disabled."); + return null; + } + + try { + FirebaseOptions options = FirebaseOptions.builder() + .setCredentials(GoogleCredentials.fromStream(credentialsStream)) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp app = FirebaseApp.initializeApp(options); + log.info("FirebaseApp initialized successfully"); + return app; + } + return FirebaseApp.getInstance(); + } finally { + credentialsStream.close(); + } + } + + @Bean + public FirebaseMessaging firebaseMessaging(FirebaseApp firebaseApp) { + if (firebaseApp == null) { + log.warn("FirebaseApp is null. FirebaseMessaging will not be available."); + return null; + } + return FirebaseMessaging.getInstance(firebaseApp); + } + + private InputStream getCredentialsStream() throws IOException { + // 1. 먼저 파일 경로 방식 시도 (기존 방식 - 로컬 개발용) + if (StringUtils.hasText(firebaseCredentialsPath)) { + try { + if (firebaseCredentialsPath.startsWith("classpath:")) { + ClassPathResource resource = new ClassPathResource( + firebaseCredentialsPath.replace("classpath:", "") + ); + return resource.getInputStream(); + } else { + return new FileInputStream(firebaseCredentialsPath); + } + } catch (IOException e) { + log.warn("Failed to load Firebase credentials from file: {}", e.getMessage()); + } + } + + // 2. 환경변수 방식 시도 (프로덕션용) + if (StringUtils.hasText(privateKey) && StringUtils.hasText(privateKeyId)) { + String firebaseConfig = buildFirebaseConfig(); + return new ByteArrayInputStream(firebaseConfig.getBytes(StandardCharsets.UTF_8)); + } + + // 3. 둘 다 실패한 경우 + log.error("No Firebase credentials found. Check firebase.credentials.path or firebase.private-key/firebase.private-key-id"); + return null; + } + + private String buildFirebaseConfig() { + // 환경변수의 \n을 실제 개행 문자로 변환 + String formattedPrivateKey = privateKey.replace("\\n", "\n"); + + return String.format(""" + { + "type": "service_account", + "project_id": "%s", + "private_key_id": "%s", + "private_key": "%s", + "client_email": "%s", + "client_id": "%s", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/%s", + "universe_domain": "googleapis.com" + } + """, + projectId, + privateKeyId, + formattedPrivateKey, + clientEmail, + clientId, + clientEmail.replace("@", "%40") + ); + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/PageConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/PageConfig.java new file mode 100644 index 000000000..3bf480b43 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/PageConfig.java @@ -0,0 +1,9 @@ +package com.example.surveyapi.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.web.config.EnableSpringDataWebSupport; + +@Configuration +@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) +public class PageConfig { +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/QuerydslConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/QuerydslConfig.java new file mode 100644 index 000000000..899819ec2 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/QuerydslConfig.java @@ -0,0 +1,21 @@ +package com.example.surveyapi.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class QuerydslConfig { + + private final EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/RedisConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/RedisConfig.java new file mode 100644 index 000000000..95c29f597 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/RedisConfig.java @@ -0,0 +1,56 @@ +package com.example.surveyapi.global.config; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.cache.RedisCacheManagerBuilderCustomizer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@EnableCaching +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort); + + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory factory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + template.setKeySerializer(new StringRedisSerializer()); + template.setHashKeySerializer(new StringRedisSerializer()); + return template; + } + + @Bean + public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() { + return (builder) -> { + RedisCacheConfiguration surveyDetailsConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofHours(4)); + + RedisCacheConfiguration surveyInfoConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofHours(4)); + + builder.withCacheConfiguration("surveyDetails", surveyDetailsConfig) + .withCacheConfiguration("surveyInfo", surveyInfoConfig); + }; + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/SchedulingConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/SchedulingConfig.java new file mode 100644 index 000000000..ca878f145 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package com.example.surveyapi.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/SecurityConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/SecurityConfig.java new file mode 100644 index 000000000..cd28d2643 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/SecurityConfig.java @@ -0,0 +1,52 @@ +package com.example.surveyapi.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.example.surveyapi.global.auth.jwt.JwtAccessDeniedHandler; +import com.example.surveyapi.global.auth.jwt.JwtAuthenticationEntryPoint; +import com.example.surveyapi.global.auth.jwt.JwtFilter; +import com.example.surveyapi.global.auth.jwt.JwtUtil; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final RedisTemplate redisTemplate; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/signup", "/api/auth/login").permitAll() + .requestMatchers("/api/surveys/participations/count").permitAll() + .requestMatchers("/api/auth/kakao/login").permitAll() + .requestMatchers("/api/auth/naver/login").permitAll() + .requestMatchers("/api/auth/google/login").permitAll() + .requestMatchers("/error").permitAll() + .requestMatchers("/actuator/**").permitAll() + .anyRequest().authenticated()) + .addFilterBefore(new JwtFilter(jwtUtil, redisTemplate), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/OAuthApiClientConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/OAuthApiClientConfig.java new file mode 100644 index 000000000..34e914db5 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/OAuthApiClientConfig.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.global.config.client; + +import com.example.surveyapi.global.client.OAuthApiClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class OAuthApiClientConfig { + + @Bean + public OAuthApiClient oAuthApiClient(RestClient restClient) { + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(OAuthApiClient.class); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ParticipationApiClientConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ParticipationApiClientConfig.java new file mode 100644 index 000000000..c52421434 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ParticipationApiClientConfig.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.global.config.client; + +import com.example.surveyapi.global.client.ParticipationApiClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class ParticipationApiClientConfig { + + @Bean + public ParticipationApiClient participationApiClient(RestClient restClient) { + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(ParticipationApiClient.class); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ProjectApiClientConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ProjectApiClientConfig.java new file mode 100644 index 000000000..5618c7beb --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ProjectApiClientConfig.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.global.config.client; + +import com.example.surveyapi.global.client.ProjectApiClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class ProjectApiClientConfig { + + @Bean + public ProjectApiClient projectApiClient(RestClient restClient) { + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(ProjectApiClient.class); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/RestClientConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/RestClientConfig.java new file mode 100644 index 000000000..bcc3976cd --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/RestClientConfig.java @@ -0,0 +1,57 @@ +package com.example.surveyapi.global.config.client; + +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.core5.util.Timeout; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import java.time.Duration; + +@Configuration +public class RestClientConfig { + + @Value("${api.base-url}") + private String baseUrl; + + @Bean + public RestClient restClient() { + return RestClient.builder() + .baseUrl(baseUrl) + .requestFactory(clientHttpRequestFactory(httpClient(poolingHttpClientConnectionManager()))) + .build(); + } + + @Bean + public ClientHttpRequestFactory clientHttpRequestFactory(CloseableHttpClient httpClient) { + return new HttpComponentsClientHttpRequestFactory(httpClient); + } + + @Bean + public CloseableHttpClient httpClient(PoolingHttpClientConnectionManager poolingHttpClientConnectionManager) { + RequestConfig requestConfig = RequestConfig.custom() + .setConnectionRequestTimeout(Timeout.ofMilliseconds(3000)) + .setResponseTimeout(Timeout.ofMilliseconds(5000)) + .build(); + + return HttpClients.custom() + .setConnectionManager(poolingHttpClientConnectionManager) + .setDefaultRequestConfig(requestConfig) + .build(); + } + + @Bean + public PoolingHttpClientConnectionManager poolingHttpClientConnectionManager() { + PoolingHttpClientConnectionManager manager = new PoolingHttpClientConnectionManager(); + manager.setMaxTotal(20); + manager.setDefaultMaxPerRoute(5); + return manager; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ShareApiClientConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ShareApiClientConfig.java new file mode 100644 index 000000000..aec7d3617 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/ShareApiClientConfig.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.global.config.client; + +import com.example.surveyapi.global.client.ShareApiClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class ShareApiClientConfig { + + @Bean + public ShareApiClient shareApiClient(RestClient restClient) { + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(ShareApiClient.class); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/StatisticApiClientConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/StatisticApiClientConfig.java new file mode 100644 index 000000000..ed33ecc24 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/StatisticApiClientConfig.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.global.config.client; + +import com.example.surveyapi.global.client.StatisticApiClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class StatisticApiClientConfig { + + @Bean + public StatisticApiClient statisticApiClient(RestClient restClient) { + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(StatisticApiClient.class); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/SurveyApiClientConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/SurveyApiClientConfig.java new file mode 100644 index 000000000..48884126f --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/SurveyApiClientConfig.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.global.config.client; + +import com.example.surveyapi.global.client.SurveyApiClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class SurveyApiClientConfig { + + @Bean + public SurveyApiClient surveyApiClient(RestClient restClient) { + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(SurveyApiClient.class); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/UserApiClientConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/UserApiClientConfig.java new file mode 100644 index 000000000..a835ae21c --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/client/UserApiClientConfig.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.global.config.client; + +import com.example.surveyapi.global.client.UserApiClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.support.RestClientAdapter; +import org.springframework.web.service.invoker.HttpServiceProxyFactory; + +@Configuration +public class UserApiClientConfig { + + @Bean + public UserApiClient userApiClient(RestClient restClient) { + RestClientAdapter adapter = RestClientAdapter.create(restClient); + HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build(); + return factory.createClient(UserApiClient.class); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/event/RabbitMQBindingConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/event/RabbitMQBindingConfig.java new file mode 100644 index 000000000..a9c839c6d --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/event/RabbitMQBindingConfig.java @@ -0,0 +1,153 @@ +package com.example.surveyapi.global.config.event; + +import java.util.Map; + +import org.springframework.amqp.core.Binding; +import org.springframework.amqp.core.BindingBuilder; +import org.springframework.amqp.core.CustomExchange; +import org.springframework.amqp.core.Queue; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.example.surveyapi.global.event.RabbitConst; + +@Configuration +public class RabbitMQBindingConfig { + + @Bean + public TopicExchange exchange() { + return new TopicExchange(RabbitConst.EXCHANGE_NAME); + } + + @Bean + public CustomExchange customExchange() { + return new CustomExchange( + RabbitConst.DELAYED_EXCHANGE_NAME, + "x-delayed-message", + true, + false, + Map.of("x-delayed-type", "topic") + ); + } + + @Bean + public Queue queueUser() { + return new Queue(RabbitConst.QUEUE_NAME_USER, true); + } + + @Bean + public Queue queueSurvey() { + return new Queue(RabbitConst.QUEUE_NAME_SURVEY, true); + } + + @Bean + public Queue queueParticipation() { + return new Queue(RabbitConst.QUEUE_NAME_PARTICIPATION, true); + } + + @Bean + public Queue queueShare() { + return new Queue(RabbitConst.QUEUE_NAME_SHARE, true); + } + + @Bean + public Queue queueStatistic() { + return new Queue(RabbitConst.QUEUE_NAME_STATISTIC, true); + } + + @Bean + public Queue queueProject() { + return new Queue(RabbitConst.QUEUE_NAME_PROJECT, true); + } + + @Bean + public Binding bindingStatistic(Queue queueStatistic, TopicExchange exchange) { + return BindingBuilder + .bind(queueStatistic) + .to(exchange) + .with(RabbitConst.ROUTING_KEY_SURVEY_ACTIVE); + } + + @Bean + public Binding bindingShare(Queue queueShare, TopicExchange exchange) { + return BindingBuilder + .bind(queueShare) + .to(exchange) + .with(RabbitConst.ROUTING_KEY_SURVEY_ACTIVE); + } + + @Bean + public Binding bindingShareProject(Queue queueShare, TopicExchange exchange) { + return BindingBuilder + .bind(queueShare) + .to(exchange) + .with(RabbitConst.ROUTING_KEY_PROJECT_CREATED); + } + + @Bean + public Binding bindingUser(Queue queueUser, TopicExchange exchange) { + return BindingBuilder + .bind(queueUser) + .to(exchange) + .with(RabbitConst.ROUTING_KEY_SURVEY_ACTIVE); + } + + @Bean + public Binding bindingStatisticParticipation(Queue queueStatistic, TopicExchange exchange) { + return BindingBuilder + .bind(queueStatistic) + .to(exchange) + .with("participation.*"); + } + + @Bean + public Binding bindingUserWithdrawToProjectQueue(Queue queueProject, TopicExchange exchange) { + return BindingBuilder + .bind(queueProject) + .to(exchange) + .with(RabbitConst.ROUTING_KEY_USER_WITHDRAW); + } + + @Bean + public Binding bindingProject(Queue queueProject, TopicExchange exchange) { + return BindingBuilder + .bind(queueProject) + .to(exchange) + .with("project.*"); + } + + @Bean + public Binding bindingSurveyFromProjectClosed(Queue queueSurvey, TopicExchange exchange) { + return BindingBuilder + .bind(queueSurvey) + .to(exchange) + .with(RabbitConst.ROUTING_KEY_PROJECT_DELETED); + } + + @Bean + public Binding bindingSurveyStartDue(Queue queueSurvey, CustomExchange customExchange) { + return BindingBuilder + .bind(queueSurvey) + .to(customExchange) + .with(RabbitConst.ROUTING_KEY_SURVEY_START_DUE) + .noargs(); + } + + @Bean + public Binding bindingSurveyEndDue(Queue queueSurvey, CustomExchange customExchange) { + return BindingBuilder + .bind(queueSurvey) + .to(customExchange) + .with(RabbitConst.ROUTING_KEY_SURVEY_END_DUE) + .noargs(); + } + + @Bean + public Binding bindingUserParticipation(Queue queueUser, TopicExchange exchange) { + return BindingBuilder + .bind(queueUser) + .to(exchange) + .with(RabbitConst.ROUTING_KEY_PARTICIPATION_CREATE); + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/config/event/RabbitMQConfig.java b/shared-kernel/src/main/java/com/example/surveyapi/global/config/event/RabbitMQConfig.java new file mode 100644 index 000000000..f30f9c958 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/config/event/RabbitMQConfig.java @@ -0,0 +1,39 @@ +package com.example.surveyapi.global.config.event; + +import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class RabbitMQConfig { + + private final ConnectionFactory connectionFactory; + + @Bean + public SimpleRabbitListenerContainerFactory defaultListenerContainerFactory() { + + SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory(); + factory.setConnectionFactory(connectionFactory); + + factory.setConsumerBatchEnabled(false); + factory.setMessageConverter(jsonMessageConverter()); + + // 동시 처리 설정 + factory.setConcurrentConsumers(3); + factory.setMaxConcurrentConsumers(5); + + return factory; + } + + // JSON 메시지 변환기 + @Bean + public MessageConverter jsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/dto/ApiResponse.java b/shared-kernel/src/main/java/com/example/surveyapi/global/dto/ApiResponse.java new file mode 100644 index 000000000..32f8a4acf --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/dto/ApiResponse.java @@ -0,0 +1,38 @@ +package com.example.surveyapi.global.dto; + +import java.time.LocalDateTime; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ApiResponse { + private boolean success; + private String message; + private T data; + private LocalDateTime timestamp; + + private ApiResponse(boolean success, String message, T data) { + this.success = success; + this.message = message; + this.data = data; + this.timestamp = LocalDateTime.now(); + } + + public static ApiResponse success(String message, T data) { + return new ApiResponse<>(true, message, data); + } + + public static ApiResponse success(String message) { + return new ApiResponse<>(true, message, null); + } + + public static ApiResponse error(String message, T data) { + return new ApiResponse<>(false, message, data); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(false, message, null); + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/dto/ExternalApiResponse.java b/shared-kernel/src/main/java/com/example/surveyapi/global/dto/ExternalApiResponse.java new file mode 100644 index 000000000..46f024464 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/dto/ExternalApiResponse.java @@ -0,0 +1,33 @@ +package com.example.surveyapi.global.dto; + +import java.time.LocalDateTime; + +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Getter +@Slf4j +@NoArgsConstructor +public class ExternalApiResponse { + private boolean success; + private String message; + private Object data; + private LocalDateTime timestamp; + + private void throwIfFailed() { + if (!success) { + //TODO : 로깅 고도화 + log.warn("External API 호출 실패 - message: {}, timestamp: {}", message, timestamp); + throw new CustomException(CustomErrorCode.SERVER_ERROR, message); + } + } + + public Object getOrThrow() { + throwIfFailed(); + return data; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/EventCode.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/EventCode.java new file mode 100644 index 000000000..e0d625317 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/EventCode.java @@ -0,0 +1,14 @@ +package com.example.surveyapi.global.event; + +public enum EventCode { + SURVEY_CREATED, + SURVEY_UPDATED, + SURVEY_DELETED, + SURVEY_ACTIVATED, + USER_WITHDRAW, + PROJECT_STATE_CHANGED, + PROJECT_DELETED, + PARTICIPATION_CREATED, + PARTICIPATION_UPDATED, + PROJECT_CREATED +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/RabbitConst.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/RabbitConst.java new file mode 100644 index 000000000..84d8d430e --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/RabbitConst.java @@ -0,0 +1,31 @@ +package com.example.surveyapi.global.event; + +public class RabbitConst { + public static final String EXCHANGE_NAME = "domain.event.exchange"; + public static final String DELAYED_EXCHANGE_NAME = "domain.event.exchange.delayed"; + + public static final String QUEUE_NAME_USER = "queue.user"; + public static final String QUEUE_NAME_SURVEY = "queue.survey"; + public static final String QUEUE_NAME_STATISTIC = "queue.statistic"; + public static final String QUEUE_NAME_SHARE = "queue.share"; + public static final String QUEUE_NAME_PROJECT = "queue.project"; + public static final String QUEUE_NAME_PARTICIPATION = "queue.participation"; + + public static final String ROUTING_KEY_SURVEY_ACTIVE = "survey.activated"; + public static final String ROUTING_KEY_SURVEY_START_DUE = "survey.start.due"; + public static final String ROUTING_KEY_SURVEY_END_DUE = "survey.end.due"; + + public static final String ROUTING_KEY_PROJECT_ACTIVE = "project.activated"; + + public static final String ROUTING_KEY_USER_WITHDRAW = "survey.user.withdraw"; + public static final String ROUTING_KEY_PARTICIPATION_CREATE = "participation.created"; + public static final String ROUTING_KEY_PARTICIPATION_UPDATE = "participation.updated"; + public static final String ROUTING_KEY_PROJECT_STATE_CHANGED = "project.state"; + public static final String ROUTING_KEY_PROJECT_DELETED = "project.deleted"; + public static final String ROUTING_KEY_PROJECT_CREATED = "project.created"; + + // DLQ 관련 상수 + public static final String DEAD_LETTER_EXCHANGE = "domain.event.exchange.dlq"; + public static final String DEAD_LETTER_QUEUE_SURVEY = "queue.survey.dlq"; + public static final String ROUTING_KEY_SURVEY_DLQ = "survey.dlq"; +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationCreatedGlobalEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationCreatedGlobalEvent.java new file mode 100644 index 000000000..fda530614 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationCreatedGlobalEvent.java @@ -0,0 +1,69 @@ +package com.example.surveyapi.global.event.participation; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Getter; + +@Getter +public class ParticipationCreatedGlobalEvent implements ParticipationGlobalEvent { + + private final Long participationId; + private final Long surveyId; + private final Long userId; + private final ParticipantInfoDto demographic; + private final LocalDateTime completedAt; + private final List answers; + + public ParticipationCreatedGlobalEvent(Long participationId, Long surveyId, Long userId, + ParticipantInfoDto demographic, + LocalDateTime completedAt, List answers) { + this.participationId = participationId; + this.surveyId = surveyId; + this.userId = userId; + this.demographic = demographic; + this.completedAt = completedAt; + this.answers = answers; + } + + @Getter + public static class ParticipantInfoDto { + + private final LocalDate birth; + private final String gender; + private final RegionDto region; + + public ParticipantInfoDto(LocalDate birth, String gender, RegionDto region) { + this.birth = birth; + this.gender = gender; + this.region = region; + } + } + + @Getter + public static class RegionDto { + + private final String province; + private final String district; + + public RegionDto(String province, String district) { + this.province = province; + this.district = district; + } + } + + @Getter + public static class Answer { + + private final Long questionId; + private final List choiceIds; + private final String responseText; + + public Answer(Long questionId, List choiceIds, String responseText) { + this.questionId = questionId; + this.choiceIds = choiceIds; + this.responseText = responseText; + } + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationGlobalEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationGlobalEvent.java new file mode 100644 index 000000000..dee0dc8ec --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationGlobalEvent.java @@ -0,0 +1,4 @@ +package com.example.surveyapi.global.event.participation; + +public interface ParticipationGlobalEvent { +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationUpdatedGlobalEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationUpdatedGlobalEvent.java new file mode 100644 index 000000000..e664ac865 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/participation/ParticipationUpdatedGlobalEvent.java @@ -0,0 +1,39 @@ +package com.example.surveyapi.global.event.participation; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.Getter; + +@Getter +public class ParticipationUpdatedGlobalEvent implements ParticipationGlobalEvent { + + private final Long participationId; + private final Long surveyId; + private final Long userId; + private final LocalDateTime completedAt; + private final List answers; + + public ParticipationUpdatedGlobalEvent(Long participationId, Long surveyId, Long userId, + LocalDateTime completedAt, List answers) { + this.participationId = participationId; + this.surveyId = surveyId; + this.userId = userId; + this.completedAt = completedAt; + this.answers = answers; + } + + @Getter + private static class Answer { + + private final Long questionId; + private final List choiceIds; + private final String responseText; + + public Answer(Long questionId, List choiceIds, String responseText) { + this.questionId = questionId; + this.choiceIds = choiceIds; + this.responseText = responseText; + } + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectCreatedEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectCreatedEvent.java new file mode 100644 index 000000000..fd9543315 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectCreatedEvent.java @@ -0,0 +1,21 @@ +package com.example.surveyapi.global.event.project; + +import java.time.LocalDateTime; + +import com.example.surveyapi.global.event.EventCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProjectCreatedEvent implements ProjectEvent { + private Long projectId; + private Long ownerId; + private LocalDateTime periodEnd; + + @Override + public EventCode getEventCode() { + return EventCode.PROJECT_CREATED; + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectDeletedEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectDeletedEvent.java new file mode 100644 index 000000000..2728ae762 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectDeletedEvent.java @@ -0,0 +1,20 @@ +package com.example.surveyapi.global.event.project; + +import com.example.surveyapi.global.event.EventCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProjectDeletedEvent implements ProjectEvent { + + private final Long projectId; + private final String projectName; + private final Long deleterId; + + @Override + public EventCode getEventCode() { + return EventCode.PROJECT_DELETED; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectEvent.java new file mode 100644 index 000000000..d6c5f909a --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectEvent.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.global.event.project; + +import com.example.surveyapi.global.event.EventCode; + +public interface ProjectEvent { + EventCode getEventCode(); +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectManagerAddedEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectManagerAddedEvent.java new file mode 100644 index 000000000..e69de29bb diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectMemberAddedEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectMemberAddedEvent.java new file mode 100644 index 000000000..e69de29bb diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectStateChangedEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectStateChangedEvent.java new file mode 100644 index 000000000..353f7fc01 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/project/ProjectStateChangedEvent.java @@ -0,0 +1,18 @@ +package com.example.surveyapi.global.event.project; + +import com.example.surveyapi.global.event.EventCode; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ProjectStateChangedEvent implements ProjectEvent { + private final Long projectId; + private final String projectState; + + @Override + public EventCode getEventCode() { + return EventCode.PROJECT_STATE_CHANGED; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyActivateEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyActivateEvent.java new file mode 100644 index 000000000..c1060cd9f --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyActivateEvent.java @@ -0,0 +1,21 @@ +package com.example.surveyapi.global.event.survey; + +import java.time.LocalDateTime; + +import lombok.Getter; + +@Getter +public class SurveyActivateEvent implements SurveyEvent { + + private Long surveyId; + private Long creatorId; + private String surveyStatus; + private LocalDateTime endTime; + + public SurveyActivateEvent(Long surveyId, Long creatorId, String surveyStatus, LocalDateTime endTime) { + this.surveyId = surveyId; + this.creatorId = creatorId; + this.surveyStatus = surveyStatus; + this.endTime = endTime; + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyEndDueEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyEndDueEvent.java new file mode 100644 index 000000000..e739b1a6e --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyEndDueEvent.java @@ -0,0 +1,18 @@ +package com.example.surveyapi.global.event.survey; + +import java.time.LocalDateTime; + +import lombok.Getter; + +@Getter +public class SurveyEndDueEvent implements SurveyEvent { + private final Long surveyId; + private final Long creatorId; + private final LocalDateTime scheduledAt; + + public SurveyEndDueEvent(Long surveyId, Long creatorId, LocalDateTime scheduledAt) { + this.surveyId = surveyId; + this.creatorId = creatorId; + this.scheduledAt = scheduledAt; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyEvent.java new file mode 100644 index 000000000..87648fd44 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyEvent.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.global.event.survey; + +public interface SurveyEvent { + Long getSurveyId(); +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyStartDueEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyStartDueEvent.java new file mode 100644 index 000000000..9dc59072d --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/survey/SurveyStartDueEvent.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.global.event.survey; + +import java.time.LocalDateTime; + +import lombok.Getter; + +@Getter +public class SurveyStartDueEvent implements SurveyEvent { + + private final Long surveyId; + private final Long creatorId; + private final LocalDateTime scheduledAt; + + public SurveyStartDueEvent(Long surveyId, Long creatorId, LocalDateTime scheduledAt) { + this.surveyId = surveyId; + this.creatorId = creatorId; + this.scheduledAt = scheduledAt; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/user/UserWithdrawEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/user/UserWithdrawEvent.java new file mode 100644 index 000000000..3d2f6886e --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/user/UserWithdrawEvent.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.global.event.user; + +import lombok.Getter; + +@Getter +public class UserWithdrawEvent implements WithdrawEvent { + + private final Long userId; + + public UserWithdrawEvent(Long userId) { + this.userId = userId; + } +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/event/user/WithdrawEvent.java b/shared-kernel/src/main/java/com/example/surveyapi/global/event/user/WithdrawEvent.java new file mode 100644 index 000000000..d06381862 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/event/user/WithdrawEvent.java @@ -0,0 +1,4 @@ +package com.example.surveyapi.global.event.user; + +public interface WithdrawEvent { +} diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/exception/CustomErrorCode.java b/shared-kernel/src/main/java/com/example/surveyapi/global/exception/CustomErrorCode.java new file mode 100644 index 000000000..42464269f --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/exception/CustomErrorCode.java @@ -0,0 +1,89 @@ +package com.example.surveyapi.global.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public enum CustomErrorCode { + + EMAIL_DUPLICATED(HttpStatus.CONFLICT,"사용중인 이메일입니다."), + NICKNAME_DUPLICATED(HttpStatus.CONFLICT,"사용중인 닉네임입니다."), + WRONG_PASSWORD(HttpStatus.UNAUTHORIZED, "비밀번호가 틀렸습니다"), + GRADE_POINT_NOT_FOUND(HttpStatus.NOT_FOUND, "등급 및 포인트를 조회 할 수 없습니다"), + EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND, "이메일을 찾을 수 없습니다."), + NOT_FOUND_TOKEN(HttpStatus.NOT_FOUND,"토큰이 유효하지 않습니다."), + NOT_FOUND_SURVEY(HttpStatus.NOT_FOUND, "설문이 존재하지 않습니다"), + STATUS_INVALID_FORMAT(HttpStatus.BAD_REQUEST, "잘못된 상태코드입니다."), + INVALID_PERMISSION(HttpStatus.FORBIDDEN, "작성 권한이 없습니다"), + CONFLICT(HttpStatus.CONFLICT, "요청이 충돌합니다."), + USERID_NOT_FOUND(HttpStatus.NOT_FOUND,"유저 ID를 찾을수 없습니다."), + INVALID_TOKEN(HttpStatus.NOT_FOUND,"유효하지 않은 토큰입니다."), + ACCESS_TOKEN_NOT_EXPIRED(HttpStatus.BAD_REQUEST,"아직 액세스 토큰이 만료되지 않았습니다."), + NOT_FOUND_REFRESH_TOKEN(HttpStatus.NOT_FOUND,"리프레쉬 토큰이 없습니다."), + MISMATCH_REFRESH_TOKEN(HttpStatus.BAD_REQUEST,"리프레쉬 토큰 맞지 않습니다."), + PROJECT_ROLE_OWNER(HttpStatus.CONFLICT,"소유한 프로젝트가 존재합니다"), + SURVEY_IN_PROGRESS(HttpStatus.CONFLICT,"참여중인 설문이 존재합니다."), + PROVIDER_ID_NOT_FOUNT(HttpStatus.NOT_FOUND,"해당 providerId로 가입된 사용자가 존재하지 않습니다"), + OAUTH_ACCESS_TOKEN_FAILED(HttpStatus.BAD_REQUEST,"소셜 로그인 인증에 실패했습니다"), + EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR,"외부 API 오류 발생했습니다."), + NOT_FOUND_ROUTING_KEY(HttpStatus.NOT_FOUND,"라우팅키를 찾을 수 없습니다."), + + // 프로젝트 에러 + START_DATE_AFTER_END_DATE(HttpStatus.BAD_REQUEST, "시작일은 종료일보다 이후일 수 없습니다."), + DUPLICATE_PROJECT_NAME(HttpStatus.BAD_REQUEST, "중복 프로젝트 이름입니다."), + NOT_FOUND_PROJECT(HttpStatus.NOT_FOUND, "프로젝트가 존재하지 않습니다."), + NOT_FOUND_MANAGER(HttpStatus.NOT_FOUND, "담당자가 존재하지 않습니다."), + INVALID_PROJECT_STATE(HttpStatus.BAD_REQUEST, "종료된 프로젝트 입니다."), + INVALID_STATE_TRANSITION(HttpStatus.BAD_REQUEST, "PENDING -> IN_PROGRESS -> CLOSED 순서로만 변경 가능합니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "접근이 거부되었습니다."), + ALREADY_REGISTERED_MANAGER(HttpStatus.CONFLICT, "이미 등록된 담당자입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "유저를 찾을 수 없습니다"), + CANNOT_CHANGE_OWNER_ROLE(HttpStatus.BAD_REQUEST, "OWNER는(로) 변경할 수 없습니다"), + CANNOT_DELETE_SELF_OWNER(HttpStatus.BAD_REQUEST, "OWNER 본인은 삭제할 수 없습니다."), + ALREADY_REGISTERED_MEMBER(HttpStatus.CONFLICT, "이미 등록된 인원입니다."), + PROJECT_MEMBER_LIMIT_EXCEEDED(HttpStatus.CONFLICT, "프로젝트 최대 인원수를 초과하였습니다."), + NOT_FOUND_MEMBER(HttpStatus.NOT_FOUND, "프로젝트에 참여한 이용자가 아닙니다."), + CANNOT_TRANSFER_TO_SELF(HttpStatus.BAD_REQUEST, "자기 자신에게 소유권 이전 불가합니다."), + OPTIMISTIC_LOCK_CONFLICT(HttpStatus.CONFLICT, "데이터가 다른 사용자에 의해 수정되었습니다. 다시 시도해주세요."), + + // 통계 에러 + STATISTICS_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 생성된 통계입니다."), + STATISTICS_NOT_FOUND(HttpStatus.NOT_FOUND, "통계를 찾을 수 없습니다."), + ANSWER_TYPE_NOT_FOUND(HttpStatus.NOT_FOUND, "올바르지 않은 응답 타입입니다."), + STATISTICS_ALERADY_DONE(HttpStatus.CONFLICT, "이미 종료된 통계입니다."), + + // 참여 에러 + NOT_FOUND_PARTICIPATION(HttpStatus.NOT_FOUND, "참여 응답이 존재하지 않습니다."), + ACCESS_DENIED_PARTICIPATION_VIEW(HttpStatus.FORBIDDEN, "본인의 참여 기록만 조회할 수 있습니다."), + SURVEY_ALREADY_PARTICIPATED(HttpStatus.CONFLICT, "이미 참여한 설문입니다."), + SURVEY_NOT_ACTIVE(HttpStatus.BAD_REQUEST, "해당 설문은 현재 참여할 수 없습니다."), + CANNOT_UPDATE_RESPONSE(HttpStatus.BAD_REQUEST, "해당 설문의 응답은 수정할 수 없습니다."), + REQUIRED_QUESTION_NOT_ANSWERED(HttpStatus.BAD_REQUEST, "필수 질문에 대해 답변하지 않았습니다."), + INVALID_SURVEY_QUESTION(HttpStatus.BAD_REQUEST, "설문의 질문들과 응답한 질문들이 일치하지 않습니다."), + INVALID_ANSWER_TYPE(HttpStatus.BAD_REQUEST, "질문과 답변의 형식이 일치하지 않습니다."), + INVALID_CHOICE_ID(HttpStatus.BAD_REQUEST, "질문과 선택지가 일치하지 않습니다."), + + // 서버 에러 + USER_LIST_EMPTY(HttpStatus.INTERNAL_SERVER_ERROR, "회원 목록이 비어 있습니다. 데이터 상태를 확인하세요."), + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버에 장애가 생겼습니다."), + + // 공유 에러 + NOT_FOUND_SHARE(HttpStatus.NOT_FOUND, "공유 작업이 존재하지 않습니다."), + ACCESS_DENIED_SHARE(HttpStatus.FORBIDDEN, "본인의 공유 작업 내역만 조회할 수 있습니다."), + UNSUPPORTED_SHARE_METHOD(HttpStatus.BAD_REQUEST, "지원하지 않는 공유 방법 입니다."), + SHARE_EXPIRED(HttpStatus.BAD_REQUEST, "유효하지 않은 공유 링크 입니다."), + INVALID_SHARE_TYPE(HttpStatus.BAD_REQUEST, "공유 타입이 일치하지 않습니다."), + ALREADY_EXISTED_SHARE(HttpStatus.BAD_REQUEST, "이미 존재하는 공유작업입니다."), + CANNOT_CREATE_NOTIFICATION(HttpStatus.BAD_REQUEST, "알림을 전송할 수 없습니다."), + PUSH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "알림 송신에 실패했습니다."); + + private final HttpStatus httpStatus; + private final String message; + + CustomErrorCode(HttpStatus httpStatus, String message) { + this.httpStatus = httpStatus; + this.message = message; + } + +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/exception/CustomException.java b/shared-kernel/src/main/java/com/example/surveyapi/global/exception/CustomException.java new file mode 100644 index 000000000..bfd34faa3 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/exception/CustomException.java @@ -0,0 +1,29 @@ +package com.example.surveyapi.global.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final CustomErrorCode errorCode; + private final String customMessage; + + public CustomException(CustomErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.customMessage = errorCode.getMessage(); + } + + public CustomException(CustomErrorCode errorCode, String customMessage) { + super(customMessage); // enum message 대신 custom message 사용 + this.errorCode = errorCode; + this.customMessage = customMessage; + + } + + @Override + public String getMessage() { + return customMessage; + } + +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/exception/GlobalExceptionHandler.java b/shared-kernel/src/main/java/com/example/surveyapi/global/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..8a306c31e --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,129 @@ +package com.example.surveyapi.global.exception; + +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +import org.springframework.context.MessageSourceResolvable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.bind.MissingRequestHeaderException; + +import com.example.surveyapi.global.dto.ApiResponse; + +import io.jsonwebtoken.JwtException; +import jakarta.persistence.OptimisticLockException; +import lombok.extern.slf4j.Slf4j; + +/** + * 전역 예외처리 핸들러 + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + // @RequestBody + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity>> handleMethodArgumentNotValidException( + MethodArgumentNotValidException e + ) { + log.warn("Validation failed : {}", e.getMessage()); + + Map errors = new HashMap<>(); + + e.getBindingResult().getFieldErrors() + .forEach((fieldError) -> { + errors.put(fieldError.getField(), fieldError.getDefaultMessage()); + }); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error("요청 데이터 검증에 실패하였습니다.", errors)); + } + + @ExceptionHandler(CustomException.class) + protected ResponseEntity> handleCustomException(CustomException e) { + return ResponseEntity.status(e.getErrorCode().getHttpStatus()) + .body(ApiResponse.error(e.getErrorCode().getMessage())); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException e) { + return ResponseEntity.status(CustomErrorCode.ACCESS_DENIED.getHttpStatus()) + .body(ApiResponse.error(CustomErrorCode.ACCESS_DENIED.getMessage())); + } + + @ExceptionHandler(OptimisticLockException.class) + public ResponseEntity> handleOptimisticLockException(OptimisticLockException e) { + return ResponseEntity.status(CustomErrorCode.OPTIMISTIC_LOCK_CONFLICT.getHttpStatus()) + .body(ApiResponse.error(CustomErrorCode.OPTIMISTIC_LOCK_CONFLICT.getMessage())); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error("요청 데이터의 타입이 올바르지 않습니다.")); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity> handleHttpMediaTypeNotSupportedException( + HttpMediaTypeNotSupportedException e) { + return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE) + .body(ApiResponse.error("지원하지 않는 Content-Type 입니다.")); + } + + @ExceptionHandler(MissingRequestHeaderException.class) + public ResponseEntity> handleMissingRequestHeaderException(MissingRequestHeaderException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error("필수 헤더가 누락되었습니다.")); + } + + @ExceptionHandler(JwtException.class) + public ResponseEntity> handleJwtException(JwtException ex) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(ApiResponse.error("토큰이 유효하지 않습니다.")); + } + + @ExceptionHandler(Exception.class) + protected ResponseEntity> handleException(Exception e) { + log.error(e.getMessage()); + return ResponseEntity.status(CustomErrorCode.SERVER_ERROR.getHttpStatus()) + .body(ApiResponse.error("알 수 없는 오류 message : {}", e.getMessage())); + } + + // @PathVariable, @RequestParam + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity>> handleMethodValidationException( + HandlerMethodValidationException e + ) { + log.warn("Parameter validation failed: {}", e.getMessage()); + + Map errors = new HashMap<>(); + + for (MessageSourceResolvable error : e.getAllErrors()) { + String fieldName = resolveFieldName(error); + String message = Objects.requireNonNullElse(error.getDefaultMessage(), "잘못된 요청입니다."); + + errors.merge(fieldName, message, (existing, newMsg) -> existing + ", " + newMsg); + } + + if (errors.isEmpty()) { + errors.put("parameter", "파라미터 검증에 실패했습니다"); + } + + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiResponse.error("요청 파라미터 검증에 실패하였습니다.", errors)); + } + + // 필드 이름 추출 메서드 + private String resolveFieldName(MessageSourceResolvable error) { + return (error instanceof FieldError fieldError) ? fieldError.getField() : "parameter"; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/model/AbstractRoot.java b/shared-kernel/src/main/java/com/example/surveyapi/global/model/AbstractRoot.java new file mode 100644 index 000000000..601ea8e75 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/model/AbstractRoot.java @@ -0,0 +1,52 @@ +package com.example.surveyapi.global.model; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.springframework.data.annotation.Transient; +import org.springframework.data.domain.AfterDomainEventPublication; +import org.springframework.data.domain.DomainEvents; +import org.springframework.util.Assert; + +import jakarta.persistence.MappedSuperclass; + +@MappedSuperclass +public class AbstractRoot> extends BaseEntity { + + private transient final @Transient List domainEvents = new ArrayList<>(); + + public void registerEvent(T event) { + + Assert.notNull(event, "Domain event must not be null"); + + this.domainEvents.add(event); + } + + @AfterDomainEventPublication + protected void clearDomainEvents() { + this.domainEvents.clear(); + } + + @DomainEvents + protected Collection domainEvents() { + return Collections.unmodifiableList(domainEvents); + } + + protected final A andEventsFrom(A aggregate) { + + Assert.notNull(aggregate, "Aggregate must not be null"); + + this.domainEvents.addAll(aggregate.domainEvents()); + + return (A)this; + } + + protected final A andEvent(Object event) { + + registerEvent(event); + + return (A)this; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/model/BaseEntity.java b/shared-kernel/src/main/java/com/example/surveyapi/global/model/BaseEntity.java new file mode 100644 index 000000000..6c4692fbb --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/model/BaseEntity.java @@ -0,0 +1,43 @@ +package com.example.surveyapi.global.model; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import lombok.Getter; + +@MappedSuperclass +@Getter +public abstract class BaseEntity { + + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "is_deleted", nullable = false) + protected Boolean isDeleted = false; + + @PrePersist + public void prePersist() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + public void preUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + protected void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } + + // Soft delete + public void delete() { + this.isDeleted = true; + } +} \ No newline at end of file diff --git a/shared-kernel/src/main/java/com/example/surveyapi/global/util/RepositorySliceUtil.java b/shared-kernel/src/main/java/com/example/surveyapi/global/util/RepositorySliceUtil.java new file mode 100644 index 000000000..a28bfef96 --- /dev/null +++ b/shared-kernel/src/main/java/com/example/surveyapi/global/util/RepositorySliceUtil.java @@ -0,0 +1,33 @@ +package com.example.surveyapi.global.util; + +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +public final class RepositorySliceUtil { + + private RepositorySliceUtil() { + throw new UnsupportedOperationException("Utility class"); + } + + public static Pageable createPageable(int page, int size) { + return PageRequest.of(page, size); + } + + public static Pageable createPageableWithDefault(int page, int size, int defaultSize) { + int actualSize = size > 0 ? size : defaultSize; + return PageRequest.of(page, actualSize); + } + + public static Slice toSlice(List content, Pageable pageable) { + boolean hasNext = false; + if (content.size() > pageable.getPageSize()) { + hasNext = true; + content = content.subList(0, pageable.getPageSize()); + } + return new SliceImpl<>(content, pageable, hasNext); + } +} \ No newline at end of file diff --git a/src/test/java/com/example/surveyapi/SurveyApiApplicationTests.java b/shared-kernel/src/test/java/com/example/surveyapi/SurveyApiApplicationTests.java similarity index 100% rename from src/test/java/com/example/surveyapi/SurveyApiApplicationTests.java rename to shared-kernel/src/test/java/com/example/surveyapi/SurveyApiApplicationTests.java diff --git a/src/test/java/com/example/surveyapi/TestConfig.java b/shared-kernel/src/test/java/com/example/surveyapi/TestConfig.java similarity index 100% rename from src/test/java/com/example/surveyapi/TestConfig.java rename to shared-kernel/src/test/java/com/example/surveyapi/TestConfig.java diff --git a/shared-kernel/src/test/java/com/example/surveyapi/domain/survey/application/IntegrationTestBase.java b/shared-kernel/src/test/java/com/example/surveyapi/domain/survey/application/IntegrationTestBase.java new file mode 100644 index 000000000..e69de29bb diff --git a/shared-kernel/src/test/resources/application-test.yml b/shared-kernel/src/test/resources/application-test.yml new file mode 100644 index 000000000..501182587 --- /dev/null +++ b/shared-kernel/src/test/resources/application-test.yml @@ -0,0 +1,28 @@ +spring: + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: false + show-sql: false + data: + mongodb: + auto-index-creation: true + +firebase: + credentials: + path: classpath:firebase-survey-account.json + +# JWT Secret Key for test environment +jwt: + secret: + key: ${SECRET_KEY:test-secret-key-for-testing-only} + +logging: + level: + org.springframework.context.annotation: WARN + org.springframework.beans.factory: WARN + org.hibernate.SQL: WARN + org.hibernate.type.descriptor.sql.BasicBinder: WARN + com.example.surveyapi: DEBUG \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f2b8af33e..c8a4ccb55 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,5 @@ + # 개발 환경 전용 설정 spring: profiles: @@ -125,4 +126,4 @@ oauth: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_SECRET} - redirect-uri: ${GOOGLE_REDIRECT_URL} + redirect-uri: ${GOOGLE_REDIRECT_URL} \ No newline at end of file diff --git a/src/test/java/com/example/surveyapi/config/TestMockConfig.java b/src/test/java/com/example/surveyapi/config/TestMockConfig.java deleted file mode 100644 index 5abe85cd2..000000000 --- a/src/test/java/com/example/surveyapi/config/TestMockConfig.java +++ /dev/null @@ -1,53 +0,0 @@ -package com.example.surveyapi.config; - -import org.mockito.Mockito; -import org.springframework.amqp.rabbit.core.RabbitTemplate; -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Primary; -import org.springframework.cache.CacheManager; -import org.springframework.context.annotation.Bean; -import org.springframework.core.task.SyncTaskExecutor; -import org.springframework.core.task.TaskExecutor; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.mail.javamail.JavaMailSender; - -@TestConfiguration -public class TestMockConfig { - - @Bean - @Primary - public RabbitTemplate rabbitTemplate() { - return Mockito.mock(RabbitTemplate.class); - } - - @Bean - @Primary - public JavaMailSender javaMailSender() { - return Mockito.mock(JavaMailSender.class); - } - - @Bean - @Primary - public RedisConnectionFactory redisConnectionFactory() { - return Mockito.mock(RedisConnectionFactory.class); - } - - @Bean - @Primary - public RedisTemplate redisTemplate() { - return Mockito.mock(RedisTemplate.class); - } - - @Bean - @Primary - public CacheManager cacheManager() { - return Mockito.mock(CacheManager.class); - } - - @Bean(name = {"applicationTaskExecutor", "taskExecutor"}) - @Primary - public TaskExecutor syncTaskExecutor() { - return new SyncTaskExecutor(); - } -} \ No newline at end of file diff --git a/src/test/java/com/example/surveyapi/config/TestcontainersConfig.java b/src/test/java/com/example/surveyapi/config/TestcontainersConfig.java deleted file mode 100644 index cb027014f..000000000 --- a/src/test/java/com/example/surveyapi/config/TestcontainersConfig.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.example.surveyapi.config; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.testcontainers.containers.MongoDBContainer; -import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; - -@TestConfiguration -@Testcontainers -public class TestcontainersConfig { - - @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:15") - .withDatabaseName("testdb") - .withUsername("test") - .withPassword("test"); - - @Container - static final MongoDBContainer MONGODB = new MongoDBContainer("mongo:7") - .withExposedPorts(27017); - - @DynamicPropertySource - static void configureProperties(DynamicPropertyRegistry registry) { - // PostgreSQL 설정 - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); - registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop"); - registry.add("spring.jpa.properties.hibernate.dialect", () -> "org.hibernate.dialect.PostgreSQLDialect"); - - // MongoDB 설정 - registry.add("spring.data.mongodb.uri", MONGODB::getReplicaSetUrl); - registry.add("spring.data.mongodb.database", () -> "test_survey_db"); - } -} \ No newline at end of file diff --git a/statistic-module/.dockerignore b/statistic-module/.dockerignore new file mode 100644 index 000000000..86022d872 --- /dev/null +++ b/statistic-module/.dockerignore @@ -0,0 +1,64 @@ +user-module/ +survey-module/ +project-module/ +participation-module/ +share-module/ +web-app/ + +build/ +target/ +.gradle/ +out/ +bin/ + +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo + +.DS_Store +Thumbs.db +desktop.ini + +.env +.env.* +properties.env +docker/ + +*.md +docs/ +images/ +*.png +*.jpg +*.jpeg +*.gif + +test-results/ +coverage/ +*test.properties +jacoco/ + +logs/ +*.log +*.log.* + +tmp/ +temp/ +*.tmp +*.temp + +grafana/ +prometheus/ +ecs-task-definitions/ +loadtest-package/ + +.git/ +.gitignore +.gitattributes + +.github/ +.gitlab-ci.yml +.travis.yml diff --git a/statistic-module/Dockerfile b/statistic-module/Dockerfile new file mode 100644 index 000000000..134d9f0a9 --- /dev/null +++ b/statistic-module/Dockerfile @@ -0,0 +1,44 @@ +FROM eclipse-temurin:17-jdk AS builder + +WORKDIR /app + +COPY gradle/ gradle/ +COPY gradlew . +COPY build.gradle . +COPY settings.gradle . + +COPY shared-kernel/build.gradle shared-kernel/ +COPY shared-kernel/src/ shared-kernel/src/ + +COPY statistic-module/build.gradle statistic-module/ +COPY statistic-module/src/ statistic-module/src/ + +RUN ./gradlew :statistic-module:bootJar --no-daemon + +FROM eclipse-temurin:17-jre-alpine AS runtime + +RUN apk add --no-cache curl + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder /app/statistic-module/build/libs/*.jar app.jar + +RUN chown appuser:appgroup app.jar + +USER appuser + +EXPOSE 8085 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8085/actuator/health || exit 1 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+ExitOnOutOfMemoryError", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] diff --git a/statistic-module/build.gradle b/statistic-module/build.gradle new file mode 100644 index 000000000..52b62f257 --- /dev/null +++ b/statistic-module/build.gradle @@ -0,0 +1,23 @@ +jar { + enabled = true + archiveClassifier = '' +} + +bootJar { + enabled = false +} + +dependencies { + implementation project(':shared-kernel') + + runtimeOnly 'org.postgresql:postgresql' + + implementation 'co.elastic.clients:elasticsearch-java:8.11.0' + implementation 'org.springframework.data:spring-data-elasticsearch:5.1.10' + + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + testImplementation 'org.springframework.security:spring-security-test' +} \ No newline at end of file diff --git a/statistic-module/docker-compose.yml b/statistic-module/docker-compose.yml new file mode 100644 index 000000000..c691ca155 --- /dev/null +++ b/statistic-module/docker-compose.yml @@ -0,0 +1,82 @@ +version: '3.8' + +services: + statistic-service: + build: + context: .. + dockerfile: statistic-module/Dockerfile + ports: + - "8085:8085" + environment: + - SPRING_PROFILES_ACTIVE=dev + - SERVER_PORT=8085 + - MONGODB_HOST=mongodb + - MONGODB_PORT=${MONGODB_PORT:-27017} + - MONGODB_DATABASE=${MONGODB_DATABASE} + - MONGODB_USERNAME=${MONGODB_USERNAME} + - MONGODB_PASSWORD=${MONGODB_PASSWORD} + - ELASTIC_URIS=http://elasticsearch:9200 + - STATISTIC_TOKEN=${STATISTIC_TOKEN} + depends_on: + mongodb: + condition: service_healthy + elasticsearch: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8085/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - statistic-network + + mongodb: + image: mongo:7 + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD} + MONGO_INITDB_DATABASE: ${MONGODB_DATABASE} + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.runCommand('ping').ok", "--quiet"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - statistic-network + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ports: + - "9200:9200" + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + command: > + sh -c " + elasticsearch-plugin list | grep -q analysis-nori || elasticsearch-plugin install analysis-nori --batch && + /usr/local/bin/docker-entrypoint.sh + " + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - statistic-network + +volumes: + mongodb_data: + elasticsearch_data: + +networks: + statistic-network: + driver: bridge diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/api/StatisticQueryController.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/api/StatisticQueryController.java new file mode 100644 index 000000000..b9669979c --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/api/StatisticQueryController.java @@ -0,0 +1,30 @@ +package com.example.surveyapi.statistic.api; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.statistic.application.StatisticQueryService; +import com.example.surveyapi.statistic.application.dto.StatisticBasicResponse; +import com.example.surveyapi.global.dto.ApiResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class StatisticQueryController { + + private final StatisticQueryService statisticQueryService; + + @GetMapping("/api/surveys/{surveyId}/statistics/basic") + public ResponseEntity> getLiveStatistics( + @PathVariable Long surveyId + ) throws Exception { + StatisticBasicResponse liveStatistics = statisticQueryService.getSurveyStatistics(surveyId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("통계 조회 성공.", liveStatistics)); + } +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/application/StatisticQueryService.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/StatisticQueryService.java new file mode 100644 index 000000000..bc70fc342 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/StatisticQueryService.java @@ -0,0 +1,46 @@ +package com.example.surveyapi.statistic.application; + +import java.io.IOException; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.statistic.application.dto.StatisticBasicResponse; +import com.example.surveyapi.statistic.domain.query.QuestionStatistics; +import com.example.surveyapi.statistic.domain.query.StatisticQueryRepository; +import com.example.surveyapi.statistic.domain.statistic.Statistic; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StatisticQueryService { + + private final StatisticQueryRepository repo; + private final StatisticService statisticService; + + public StatisticBasicResponse getSurveyStatistics(Long surveyId) throws IOException { + Statistic statistic = statisticService.getStatistic(surveyId); + + List> initDocs = repo.findAllInitBySurveyId(surveyId); + Map> choiceCounts = repo.aggregateChoiceCounts(surveyId); + Map> textResponses = repo.findTextResponses(surveyId); + + List questionStats = QuestionStatistics.buildFrom( + initDocs, + choiceCounts, + textResponses + ); + + List dtoQuestions = questionStats.stream() + .map(StatisticBasicResponse.QuestionStat::from) + .sorted(Comparator.comparing(StatisticBasicResponse.QuestionStat::getQuestionId)) + .toList(); + + return new StatisticBasicResponse(surveyId, statistic.getFinalResponseCount(), dtoQuestions); + } +} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/application/StatisticService.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/StatisticService.java new file mode 100644 index 000000000..beaca27c9 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/StatisticService.java @@ -0,0 +1,34 @@ +package com.example.surveyapi.statistic.application; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.statistic.domain.statistic.Statistic; +import com.example.surveyapi.statistic.domain.statistic.StatisticRepository; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class StatisticService { + + private final StatisticRepository statisticRepository; + + @Transactional + public void create(Long surveyId) { + if (statisticRepository.existsById(surveyId)) { + throw new CustomException(CustomErrorCode.STATISTICS_ALREADY_EXISTS); + } + Statistic statistic = Statistic.start(surveyId); + statisticRepository.save(statistic); + } + + public Statistic getStatistic(Long surveyId) { + return statisticRepository.findById(surveyId) + .orElseThrow(() -> new CustomException(CustomErrorCode.STATISTICS_NOT_FOUND)); + } +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/application/client/SurveyDetailDto.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/client/SurveyDetailDto.java new file mode 100644 index 000000000..dab9fdcd4 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/client/SurveyDetailDto.java @@ -0,0 +1,23 @@ +package com.example.surveyapi.statistic.application.client; + +import java.util.List; + +public record SurveyDetailDto ( + Long surveyId, + String title, + List questions +) { + public record QuestionInfo ( + Long questionId, + String content, + String questionType, + int displayOrder, + List choices + ) {} + + public record ChoiceInfo ( + Long choiceId, + String content, + int displayOrder + ) {} +} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/application/client/SurveyServicePort.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/client/SurveyServicePort.java new file mode 100644 index 000000000..a5b2ccf0a --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/client/SurveyServicePort.java @@ -0,0 +1,6 @@ +package com.example.surveyapi.statistic.application.client; + +public interface SurveyServicePort { + + SurveyDetailDto getSurveyDetail(String authHeader, Long surveyId); +} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/application/dto/StatisticBasicResponse.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/dto/StatisticBasicResponse.java new file mode 100644 index 000000000..01c108da2 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/dto/StatisticBasicResponse.java @@ -0,0 +1,77 @@ +package com.example.surveyapi.statistic.application.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.example.surveyapi.statistic.domain.query.QuestionStatistics; +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.Getter; + +@Getter +public class StatisticBasicResponse { + + private final Long surveyId; + private final Long totalResponseCount; + private final List baseStats; + private final LocalDateTime generatedAt; + + public StatisticBasicResponse(Long surveyId, Long count, List baseStats) { + this.surveyId = surveyId; + this.totalResponseCount = count; + this.baseStats = baseStats; + this.generatedAt = LocalDateTime.now(); + } + + @Getter + public static class QuestionStat { + private final Long questionId; + private final String questionContent; + private final String choiceType; + private final int responseCount; + private final List choiceStats; + private final List texts; + + public QuestionStat(Long questionId, String questionContent, String choiceType, + int responseCount, List choiceStats, List texts) { + this.questionId = questionId; + this.questionContent = questionContent; + this.choiceType = choiceType; + this.responseCount = responseCount; + this.choiceStats = choiceStats; + this.texts = texts; + } + + public static QuestionStat from(QuestionStatistics stats) { + List choiceDtoStats = null; + if (stats.choices() != null) { + choiceDtoStats = stats.choices().stream() + .map(c -> new ChoiceStat(c.choiceId(), c.content(), c.count())) + .toList(); + } + + return new QuestionStat( + stats.questionId(), + stats.content(), + stats.type(), + (int) stats.responseCount(), + choiceDtoStats, + stats.texts() + ); + } + } + + @Getter + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ChoiceStat { + private final Long choiceId; + private final String choiceContent; + private final Long choiceCount; + + public ChoiceStat(Long choiceId, String choiceContent, Long choiceCount) { + this.choiceId = choiceId; + this.choiceContent = choiceContent; + this.choiceCount = choiceCount; + } + } +} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/ParticipationResponses.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/ParticipationResponses.java new file mode 100644 index 000000000..256aa95e0 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/ParticipationResponses.java @@ -0,0 +1,22 @@ +package com.example.surveyapi.statistic.application.event; + +import java.time.Instant; +import java.util.List; + +public record ParticipationResponses( + Long participationId, + Long surveyId, + Long userId, + String userGender, + String userBirthDate, + Integer userAge, + String userAgeGroup, + Instant completedAt, + List answers +) { + public record Answer( + Long questionId, + List choiceIds, + String responseText + ) {} +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/StatisticEventHandler.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/StatisticEventHandler.java new file mode 100644 index 000000000..279598626 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/StatisticEventHandler.java @@ -0,0 +1,115 @@ +package com.example.surveyapi.statistic.application.event; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.statistic.application.StatisticService; +import com.example.surveyapi.statistic.application.client.SurveyDetailDto; +import com.example.surveyapi.statistic.application.client.SurveyServicePort; +import com.example.surveyapi.statistic.domain.statistic.Statistic; +import com.example.surveyapi.statistic.domain.statisticdocument.StatisticDocument; +import com.example.surveyapi.statistic.domain.statisticdocument.StatisticDocumentFactory; +import com.example.surveyapi.statistic.domain.statisticdocument.StatisticDocumentRepository; +import com.example.surveyapi.statistic.domain.statisticdocument.dto.DocumentCreateCommand; +import com.example.surveyapi.statistic.domain.statisticdocument.dto.SurveyMetadata; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class StatisticEventHandler implements StatisticEventPort { + + private final StatisticService statisticService; + private final SurveyServicePort surveyServicePort; + private final StatisticDocumentFactory statisticDocumentFactory; + private final StatisticDocumentRepository statisticDocumentRepository; + + @Value("${jwt.statistic.token}") + private String serviceToken; + + @Override + public void handleSurveyActivateEvent(Long surveyId) { + statisticService.create(surveyId); + //TODO : 캐싱 여부 적용 + SurveyDetailDto surveyDetail = surveyServicePort.getSurveyDetail(serviceToken, surveyId); + + SurveyMetadata metadata = toSurveyMetadata(surveyDetail); + List emptyDocuments = statisticDocumentFactory.initDocuments(surveyId, metadata); + + if (!emptyDocuments.isEmpty()) { + statisticDocumentRepository.saveAll(emptyDocuments); + } + } + + @Override + public void handleParticipationEvent(ParticipationResponses responses) { + Statistic statistic = statisticService.getStatistic(responses.surveyId()); + statistic.verifyIfCounting(); + + SurveyDetailDto surveyDetail = surveyServicePort.getSurveyDetail(serviceToken, responses.surveyId()); + SurveyMetadata surveyMetadata = toSurveyMetadata(surveyDetail); + + DocumentCreateCommand command = toCreateCommand(responses); + + //TODO : survey 정보 수정 (캐싱 Or dto 분리) + + List documents = statisticDocumentFactory.createDocuments(command, surveyMetadata); + + if(!documents.isEmpty()) { + statisticDocumentRepository.saveAll(documents); + } + statistic.increaseCount(); + } + + @Override + public void handleSurveyDeactivateEvent(Long surveyId) { + + } + + private DocumentCreateCommand toCreateCommand(ParticipationResponses responses) { + List answers = responses.answers().stream() + .map(answer -> new DocumentCreateCommand.Answer( + answer.questionId(), answer.choiceIds(), answer.responseText())) + .toList(); + + return new DocumentCreateCommand( + responses.participationId(), + responses.surveyId(), + responses.userId(), + responses.userGender(), + responses.userBirthDate(), + responses.userAge(), + responses.userAgeGroup(), + responses.completedAt(), + answers + ); + } + + private SurveyMetadata toSurveyMetadata(SurveyDetailDto surveyDetailDto) { + Map questionMetadataMap = surveyDetailDto.questions().stream() + .collect(Collectors.toMap( + SurveyDetailDto.QuestionInfo::questionId, + questionInfo -> { + Map choiceMap = (questionInfo.choices() != null) ? + questionInfo.choices().stream().collect(Collectors.toMap( + SurveyDetailDto.ChoiceInfo::choiceId, + SurveyDetailDto.ChoiceInfo::content + )) : Collections.emptyMap(); + + return new SurveyMetadata.QuestionMetadata( + questionInfo.content(), + questionInfo.questionType(), + choiceMap + ); + } + )); + return new SurveyMetadata(questionMetadataMap); + } +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/StatisticEventPort.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/StatisticEventPort.java new file mode 100644 index 000000000..618dcba9b --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/application/event/StatisticEventPort.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.statistic.application.event; + +public interface StatisticEventPort { + void handleParticipationEvent(ParticipationResponses responses); + void handleSurveyActivateEvent(Long surveyId); + void handleSurveyDeactivateEvent(Long surveyId); +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/ChoiceStatistics.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/ChoiceStatistics.java new file mode 100644 index 000000000..8d68d8dac --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/ChoiceStatistics.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.statistic.domain.query; + +public record ChoiceStatistics( + long choiceId, + String content, + long count +) {} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/QuestionStatistics.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/QuestionStatistics.java new file mode 100644 index 000000000..d44f14372 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/QuestionStatistics.java @@ -0,0 +1,83 @@ +package com.example.surveyapi.statistic.domain.query; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +public record QuestionStatistics( + long questionId, + String content, + String type, + long responseCount, + List choices, + List texts +) { + public static List buildFrom( + List> initDocs, + Map> choiceCounts, + Map> textResponses + ) { + Map metaMap = new LinkedHashMap<>(); + for (var doc : initDocs) { + long qId = ((Number) doc.get("questionId")).longValue(); + String qText = Objects.toString(doc.get("questionText"), ""); + String qType = Objects.toString(doc.get("questionType"), ""); + QuestionMeta qm = metaMap.computeIfAbsent(qId, k -> new QuestionMeta(qText, qType)); + if (qm.isChoiceType() && doc.containsKey("choiceId") && doc.get("choiceId") != null) { + qm.choices.put( + ((Number) doc.get("choiceId")).intValue(), + Objects.toString(doc.get("choiceText"), "") + ); + } + } + + return metaMap.entrySet().stream() + .map(entry -> { + long qId = entry.getKey(); + QuestionMeta meta = entry.getValue(); + return meta.toStatistics(qId, choiceCounts, textResponses); + }) + .collect(Collectors.toList()); + } + + private static class QuestionMeta { + final String text; + final String type; + final Map choices = new LinkedHashMap<>(); + + QuestionMeta(String text, String type) { + this.text = text; + this.type = type; + } + + boolean isChoiceType() { + return "SINGLE_CHOICE".equals(type) || "MULTIPLE_CHOICE".equals(type); + } + + QuestionStatistics toStatistics( + long qId, + Map> allChoiceCounts, + Map> allTextResponses + ) { + if (isChoiceType()) { + Map questionCounts = allChoiceCounts.getOrDefault(qId, Collections.emptyMap()); + List choiceStats = this.choices.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> { + long actualCount = questionCounts.getOrDefault(entry.getKey(), 1L) - 1; + return new ChoiceStatistics(entry.getKey(), entry.getValue(), actualCount); + }) + .collect(Collectors.toList()); + + long totalResponses = choiceStats.stream().mapToLong(ChoiceStatistics::count).sum(); + return new QuestionStatistics(qId, this.text, this.type, totalResponses, choiceStats, null); + } else { + List texts = allTextResponses.getOrDefault(qId, Collections.emptyList()); + return new QuestionStatistics(qId, this.text, this.type, texts.size(), null, texts); + } + } + } +} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/StatisticQueryRepository.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/StatisticQueryRepository.java new file mode 100644 index 000000000..038706d5a --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/query/StatisticQueryRepository.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.statistic.domain.query; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +public interface StatisticQueryRepository { + + List> findAllInitBySurveyId(Long surveyId) throws IOException; + Map> aggregateChoiceCounts(Long surveyId) throws IOException; + Map> findTextResponses(Long surveyId) throws IOException; + +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/Statistic.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/Statistic.java new file mode 100644 index 000000000..f95da6a2e --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/Statistic.java @@ -0,0 +1,66 @@ +package com.example.surveyapi.statistic.domain.statistic; + +import java.time.LocalDateTime; + +import com.example.surveyapi.statistic.domain.statistic.enums.StatisticStatus; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; +import com.example.surveyapi.global.model.BaseEntity; + +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "statistics") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Statistic extends BaseEntity { + @Id + private Long surveyId; + private Long finalResponseCount; + + @Enumerated(EnumType.STRING) + private StatisticStatus status; + + private LocalDateTime startedAt; + private LocalDateTime endedAt; + + @Version + private Long version; + + public static Statistic start(Long surveyId) { + Statistic statistic = new Statistic(); + statistic.surveyId = surveyId; + statistic.finalResponseCount = 0L; + statistic.status = StatisticStatus.COUNTING; + statistic.startedAt = LocalDateTime.now(); + + return statistic; + } + + public void end(long finalCount) { + if (this.status == StatisticStatus.DONE) { + return; + } + this.status = StatisticStatus.DONE; + this.finalResponseCount = finalCount; + this.endedAt = LocalDateTime.now(); + } + + public void increaseCount() { + finalResponseCount++; + } + + public void verifyIfCounting() { + if (this.status != StatisticStatus.COUNTING) { + throw new CustomException(CustomErrorCode.STATISTICS_ALERADY_DONE); + } + } +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/StatisticRepository.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/StatisticRepository.java new file mode 100644 index 000000000..ba971c219 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/StatisticRepository.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.statistic.domain.statistic; + +import java.util.Optional; + +public interface StatisticRepository { + + //CRUD + Statistic save(Statistic statistic); + Optional findById(Long id); + + //exist + boolean existsById(Long id); +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/enums/StatisticStatus.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/enums/StatisticStatus.java new file mode 100644 index 000000000..c5564ae80 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statistic/enums/StatisticStatus.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.statistic.domain.statistic.enums; + +public enum StatisticStatus { + COUNTING, DONE +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocument.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocument.java new file mode 100644 index 000000000..58ebee494 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocument.java @@ -0,0 +1,74 @@ +package com.example.surveyapi.statistic.domain.statisticdocument; + +import java.time.Instant; + +import org.springframework.data.annotation.Id; +import org.springframework.data.elasticsearch.annotations.Document; +import org.springframework.data.elasticsearch.annotations.Mapping; +import org.springframework.data.elasticsearch.annotations.Setting; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Document(indexName = "statistics") +@Setting(settingPath = "elasticsearch/statistic-settings.json") +@Mapping(mappingPath = "elasticsearch/statistic-mappings.json") +@NoArgsConstructor +public class StatisticDocument { + + @Id + private String responseId; + + private Long surveyId; + + private Long questionId; + private String questionText; + private String questionType; + + private Integer choiceId; + private String choiceText; + private String responseText; + + private Long userId; + private String userGender; + private String userBirthDate; + private Integer userAge; + private String userAgeGroup; + + private Instant submittedAt; + + private StatisticDocument( + String responseId, Long surveyId, Long questionId, String questionText, String questionType, + Integer choiceId, String choiceText, String responseText, Long userId, String userGender, String userBirthDate, + Integer userAge, String userAgeGroup, Instant submittedAt + ) { + this.responseId = responseId; + this.surveyId = surveyId; + this.questionId = questionId; + this.questionText = questionText; + this.questionType = questionType; + this.choiceId = choiceId; + this.choiceText = choiceText; + this.responseText = responseText; + this.userId = userId; + this.userGender = userGender; + this.userBirthDate = userBirthDate; + this.userAge = userAge; + this.userAgeGroup = userAgeGroup; + this.submittedAt = submittedAt; + } + + public static StatisticDocument create( + String responseId, Long surveyId, Long questionId, String questionText, + String questionType, Integer choiceId, String choiceText, String responseText, + Long userId, String userGender, String userBirthDate, Integer userAge, + String userAgeGroup, Instant submittedAt) { + + return new StatisticDocument( + responseId, surveyId, questionId, questionText, questionType, + choiceId, choiceText, responseText, userId, userGender, + userBirthDate, userAge, userAgeGroup, submittedAt + ); + } +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocumentFactory.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocumentFactory.java new file mode 100644 index 000000000..697c5c83f --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocumentFactory.java @@ -0,0 +1,104 @@ +package com.example.surveyapi.statistic.domain.statisticdocument; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Stream; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.statistic.domain.statisticdocument.dto.DocumentCreateCommand; +import com.example.surveyapi.statistic.domain.statisticdocument.dto.SurveyMetadata; + +@Component +public class StatisticDocumentFactory { + + public List initDocuments(Long surveyId, SurveyMetadata metadata) { + return metadata.getQuestionMap().entrySet().stream() + .flatMap(data -> createInitialStreamForQuestion(surveyId, data.getKey(), data.getValue())) + .toList(); + } + + public List createDocuments(DocumentCreateCommand command, SurveyMetadata metadata) { + return command.answers().stream() + .flatMap(answer -> createStreamOfDocuments(command, answer, metadata)) + .filter(Objects::nonNull) + .toList(); + } + + private Stream createInitialStreamForQuestion(Long surveyId, Long questionId, SurveyMetadata.QuestionMetadata questionMeta) { + if ("SINGLE_CHOICE".equals(questionMeta.questionType()) || "MULTIPLE_CHOICE".equals(questionMeta.questionType())) { + return questionMeta.choiceMap().entrySet().stream() + .map(choiceEntry -> { + Long choiceId = choiceEntry.getKey(); + String choiceText = choiceEntry.getValue(); + return buildInitialDocument(surveyId, questionId, questionMeta, choiceId, choiceText); + }); + } + if ("LONG_ANSWER".equals(questionMeta.questionType()) || "SHORT_ANSWER".equals(questionMeta.questionType())) { + return Stream.of(buildInitialDocument(surveyId, questionId, questionMeta, null, null)); + } + return Stream.empty(); + } + + private StatisticDocument buildInitialDocument( + Long surveyId, Long questionId, + SurveyMetadata.QuestionMetadata questionMeta, Long choiceId, String choiceText + ) { + String documentId = (choiceId != null) ? + String.format("%d-%d-%d-init", surveyId, questionId, choiceId) : + String.format("%d-%d-init", surveyId, questionId); + String responseText = (choiceId != null) ? null : ""; + + return StatisticDocument.create( + documentId, + surveyId, + questionId, + questionMeta.content(), + questionMeta.questionType(), + (choiceId != null) ? choiceId.intValue() : null, + choiceText, + responseText, null, null, null, null, null, null + ); + } + + private Stream createStreamOfDocuments(DocumentCreateCommand command, + DocumentCreateCommand.Answer answer, + SurveyMetadata metadata) { + // 메타데이터에서 현재 응답에 해당하는 질문 정보를 찾는다. + return metadata.getQuestion(answer.questionId()) + .map(questionMeta -> { + // 서술형 응답 처리 + if (answer.responseText() != null && !answer.responseText().isEmpty()) { + return Stream.of(buildDocument(command, answer, questionMeta, null)); + } + // 선택형 응답 처리 (단일/다중 모두 포함) + if (answer.choiceIds() != null && !answer.choiceIds().isEmpty()) { + return answer.choiceIds().stream() + .map(choiceId -> buildDocument(command, answer, questionMeta, choiceId)); + } + // 응답 내용이 없는 경우 빈 스트림 반환 + return Stream.empty(); + }).orElse(Stream.empty()); // 질문 정보가 없는 경우 빈 스트림 반환 + } + + private StatisticDocument buildDocument(DocumentCreateCommand command, + DocumentCreateCommand.Answer answer, + SurveyMetadata.QuestionMetadata questionMeta, + Integer choiceId) { + + // 메타데이터에서 선택지 텍스트를 조회 + String choiceText = (choiceId != null) ? questionMeta.getChoiceText(choiceId).orElse(null) : null; + + // 고유한 문서 ID 생성 (서술형은 choiceId가 null) + String documentId = (choiceId != null) ? + String.format("%d-%d-%d", command.participationId(), answer.questionId(), choiceId) : + String.format("%d-%d", command.participationId(), answer.questionId()); + + return StatisticDocument.create( + documentId, command.surveyId(), answer.questionId(), questionMeta.content(), + questionMeta.questionType(), choiceId, choiceText, answer.responseText(), + command.userId(), command.userGender(), command.userBirthDate(), command.userAge(), + command.userAgeGroup(), command.completedAt() + ); + } +} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocumentRepository.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocumentRepository.java new file mode 100644 index 000000000..59237f7bb --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/StatisticDocumentRepository.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.statistic.domain.statisticdocument; + +import java.util.List; + +public interface StatisticDocumentRepository { + void saveAll(List statisticDocuments); +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/dto/DocumentCreateCommand.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/dto/DocumentCreateCommand.java new file mode 100644 index 000000000..0d2ba66c5 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/dto/DocumentCreateCommand.java @@ -0,0 +1,22 @@ +package com.example.surveyapi.statistic.domain.statisticdocument.dto; + +import java.time.Instant; +import java.util.List; + +public record DocumentCreateCommand ( + Long participationId, + Long surveyId, + Long userId, + String userGender, + String userBirthDate, + Integer userAge, + String userAgeGroup, + Instant completedAt, + List answers +) { + public record Answer( + Long questionId, + List choiceIds, + String responseText + ) {} +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/dto/SurveyMetadata.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/dto/SurveyMetadata.java new file mode 100644 index 000000000..a48eec3f3 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/domain/statisticdocument/dto/SurveyMetadata.java @@ -0,0 +1,29 @@ +package com.example.surveyapi.statistic.domain.statisticdocument.dto; + +import java.util.Map; +import java.util.Optional; + +import lombok.Getter; + +@Getter +public class SurveyMetadata { + private final Map questionMap; + + public SurveyMetadata(Map questionMap) { + this.questionMap = questionMap; + } + + public Optional getQuestion(Long questionId) { + return Optional.ofNullable(questionMap.get(questionId)); + } + + public record QuestionMetadata( + String content, + String questionType, + Map choiceMap + ) { + public Optional getChoiceText(Integer choiceId) { + return Optional.ofNullable(choiceMap.get(choiceId)); + } + } +} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/StatisticRepositoryImpl.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/StatisticRepositoryImpl.java new file mode 100644 index 000000000..34f1baf90 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/StatisticRepositoryImpl.java @@ -0,0 +1,68 @@ +package com.example.surveyapi.statistic.infra; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.statistic.domain.query.StatisticQueryRepository; +import com.example.surveyapi.statistic.domain.statistic.Statistic; +import com.example.surveyapi.statistic.domain.statistic.StatisticRepository; +import com.example.surveyapi.statistic.domain.statisticdocument.StatisticDocument; +import com.example.surveyapi.statistic.domain.statisticdocument.StatisticDocumentRepository; +import com.example.surveyapi.statistic.infra.elastic.StatisticEsClientRepository; +import com.example.surveyapi.statistic.infra.elastic.StatisticEsJpaRepository; +import com.example.surveyapi.statistic.infra.jpa.JpaStatisticRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class StatisticRepositoryImpl implements StatisticRepository, StatisticQueryRepository, + StatisticDocumentRepository { + + private final JpaStatisticRepository jpaStatisticRepository; + private final StatisticEsClientRepository clientRepository; + private final StatisticEsJpaRepository statisticElasticRepository; + + // StatisticRepository + @Override + public Statistic save(Statistic statistic) { + return jpaStatisticRepository.save(statistic); + } + + @Override + public boolean existsById(Long id) { + return jpaStatisticRepository.existsById(id); + } + + @Override + public Optional findById(Long id) { + return jpaStatisticRepository.findById(id); + } + + // StatisticDocumentRepository + @Override + public void saveAll(List statisticDocuments) { + statisticElasticRepository.saveAll(statisticDocuments); + } + + // StatisticQueryRepository + @Override + public List> findAllInitBySurveyId(Long surveyId) throws IOException { + return clientRepository.findAllInitBySurveyId(surveyId); + } + + @Override + public Map> aggregateChoiceCounts(Long surveyId) throws IOException { + return clientRepository.aggregateChoiceCounts(surveyId); + } + + @Override + public Map> findTextResponses(Long surveyId) throws IOException { + return clientRepository.findTextResponses(surveyId); + } +} + diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/adapter/SurveyServiceAdapter.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/adapter/SurveyServiceAdapter.java new file mode 100644 index 000000000..0b2baad8c --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/adapter/SurveyServiceAdapter.java @@ -0,0 +1,72 @@ +package com.example.surveyapi.statistic.infra.adapter; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.statistic.application.client.SurveyDetailDto; +import com.example.surveyapi.statistic.application.client.SurveyServicePort; +import com.example.surveyapi.global.client.SurveyApiClient; +import com.example.surveyapi.global.dto.ExternalApiResponse; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Component("statisticSurveyAdapter") +@RequiredArgsConstructor +public class SurveyServiceAdapter implements SurveyServicePort { + + private final SurveyApiClient surveyApiClient; + private final ObjectMapper objectMapper; + + @Override + public SurveyDetailDto getSurveyDetail(String authHeader, Long surveyId) { + ExternalApiResponse response = surveyApiClient.getSurveyDetail(authHeader, surveyId); + Object rawData = response.getOrThrow(); + + SurveyDetailDto surveyDetail = objectMapper.convertValue( + rawData, + new TypeReference() {} + ); + + // TODO choiceId가 생기면 바꾸기 + List patchedQuestions = surveyDetail.questions().stream() + .map(question -> { + List patchedChoices = null; + + if (question.choices() != null) { + patchedChoices = question.choices().stream() + .map(choice -> { + if (choice.choiceId() == null) { + return new SurveyDetailDto.ChoiceInfo( + (long)choice.displayOrder(), // displayOrder를 choiceId로 + choice.content(), + choice.displayOrder() + ); + } else { + return choice; + } + }) + .toList(); + } + + return new SurveyDetailDto.QuestionInfo( + question.questionId(), + question.content(), + question.questionType(), + question.displayOrder(), + patchedChoices + ); + + }) + .toList(); + + // 새로운 SurveyDetailDto 반환 + return new SurveyDetailDto( + surveyDetail.surveyId(), + surveyDetail.title(), + patchedQuestions + ); + } +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/elastic/StatisticEsClientRepository.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/elastic/StatisticEsClientRepository.java new file mode 100644 index 000000000..48c69a5bc --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/elastic/StatisticEsClientRepository.java @@ -0,0 +1,132 @@ +package com.example.surveyapi.statistic.infra.elastic; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.springframework.stereotype.Repository; + +import co.elastic.clients.elasticsearch.ElasticsearchClient; +import co.elastic.clients.elasticsearch._types.aggregations.Aggregation; +import co.elastic.clients.elasticsearch._types.aggregations.LongTermsAggregate; +import co.elastic.clients.elasticsearch.core.SearchRequest; +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class StatisticEsClientRepository { + + private final ElasticsearchClient client; + + public List> findAllInitBySurveyId(Long surveyId) throws IOException { + SearchRequest request = SearchRequest.of(s -> s + .index("statistics") + .query(q -> q + .bool(b -> b + .must(t -> t.term(term -> term.field("surveyId").value(String.valueOf(surveyId)))) + .must(t -> t.wildcard(w -> w.field("responseId").value("*-init"))) + ) + ) + .size(1000) + ); + + SearchResponse response = client.search(request, Object.class); + + return response.hits().hits().stream() + .map(Hit::source) + .map(src -> (Map) src) + .toList(); + } + + // ---------------- 선택형 집계 ---------------- + public Map> aggregateChoiceCounts(Long surveyId) throws IOException { + Aggregation byQuestionAgg = Aggregation.of(a -> a + .terms(t -> t.field("questionId")) + .aggregations("by_choice", agg -> agg.terms(tt -> tt.field("choiceId"))) + ); + + SearchRequest request = SearchRequest.of(s -> s + .index("statistics") + .query(q -> q + .bool(b -> b + .must(t -> t.term(term -> term.field("surveyId").value(surveyId))) + .mustNot(n -> n.wildcard(w -> w.field("responseId.keyword").value("*-init"))) + ) + ) + .size(0) + .aggregations("by_question", byQuestionAgg) + ); + + SearchResponse response = client.search(request, Void.class); + + Map> result = new HashMap<>(); + + var byQuestionRaw = response.aggregations().get("by_question"); + if (byQuestionRaw != null && byQuestionRaw.isLterms()) { + LongTermsAggregate byQuestion = byQuestionRaw.lterms(); + for (var qBucket : byQuestion.buckets().array()) { + Long questionId = qBucket.key(); + Map choiceCounts = new HashMap<>(); + + var byChoiceRaw = qBucket.aggregations().get("by_choice"); + if (byChoiceRaw != null && byChoiceRaw.isLterms()) { + LongTermsAggregate byChoice = byChoiceRaw.lterms(); + for (var cBucket : byChoice.buckets().array()) { + Integer choiceId = (int) cBucket.key(); + Long count = cBucket.docCount(); + choiceCounts.put(choiceId, count); + } + } + + result.put(questionId, choiceCounts); + } + } + + return result; + } + + // ---------------- 텍스트형 응답 ---------------- + public Map> findTextResponses(Long surveyId) throws IOException { + // 1. 먼저 surveyId 기준으로 서술형 질문 가져오기 + SearchRequest metaRequest = SearchRequest.of(s -> s + .index("statistics") + .query(q -> q + .bool(b -> b + .must(t -> t.term(term -> term.field("surveyId").value(surveyId))) + .mustNot(n -> n.wildcard(w -> w.field("responseId.keyword").value("*-init"))) + ) + ) + .size(1000) // 임시로 충분히 큰 수, 질문 메타를 가져오기 위해 + .source(src -> src.filter(f -> f.includes("questionId", "questionType", "responseText"))) + ); + + SearchResponse> metaResponse = + client.search(metaRequest, (Class>) (Class) Map.class); + + // 2. 서술형 질문만 필터링 + Map> result = new HashMap<>(); + for (var hit : metaResponse.hits().hits()) { + Map source = hit.source(); + String type = Objects.toString(source.get("questionType"), ""); + if ("LONG_ANSWER".equals(type) || "SHORT_ANSWER".equals(type)) { + Long qId = ((Number) source.get("questionId")).longValue(); + String text = Objects.toString(source.get("responseText"), ""); + if (!text.isBlank()) { + result.computeIfAbsent(qId, k -> new ArrayList<>()) + .add(text); + } + } + } + + // 3. 각 질문별로 최대 100개까지만 + result.replaceAll((k, v) -> v.size() > 100 ? v.subList(0, 100) : v); + + return result; + } + +} \ No newline at end of file diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/elastic/StatisticEsJpaRepository.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/elastic/StatisticEsJpaRepository.java new file mode 100644 index 000000000..254a70906 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/elastic/StatisticEsJpaRepository.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.statistic.infra.elastic; + +import org.springframework.data.elasticsearch.repository.ElasticsearchRepository; + +import com.example.surveyapi.statistic.domain.statisticdocument.StatisticDocument; + +public interface StatisticEsJpaRepository extends ElasticsearchRepository { +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/event/StatisticEventConsumer.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/event/StatisticEventConsumer.java new file mode 100644 index 000000000..4230a6a68 --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/event/StatisticEventConsumer.java @@ -0,0 +1,98 @@ +package com.example.surveyapi.statistic.infra.event; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.statistic.application.event.ParticipationResponses; +import com.example.surveyapi.statistic.application.event.StatisticEventPort; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.participation.ParticipationCreatedGlobalEvent; +import com.example.surveyapi.global.event.survey.SurveyActivateEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@RabbitListener(queues = RabbitConst.QUEUE_NAME_STATISTIC) +public class StatisticEventConsumer { + + private final StatisticEventPort statisticEventPort; + + @RabbitHandler + public void consumeParticipationCreatedEvent(ParticipationCreatedGlobalEvent event) { + try{ + log.info("ParticipationCreatedGlobalEvent received: {}", event); + ParticipationResponses responses = convertEventToDto(event); + statisticEventPort.handleParticipationEvent(responses); + } catch (Exception e) { + log.error("메시지 처리 중 에러 발생: {}", event, e); + } + } + + @RabbitHandler + public void consumeSurveyActivateEvent(SurveyActivateEvent event) { + try{ + log.info("get surveyEvent : {}", event); + log.info("surveyActivateEvent received: {}", event.getSurveyStatus()); + if (event.getSurveyStatus().equals("IN_PROGRESS")) { + statisticEventPort.handleSurveyActivateEvent(event.getSurveyId()); + return; + } + if (event.getSurveyStatus().equals("CLOSED")) { + statisticEventPort.handleSurveyDeactivateEvent(event.getSurveyId()); + } + } catch (Exception e) { + log.error("메시지 처리 중 에러 발생: {}", event, e); + } + } + + private ParticipationResponses convertEventToDto(ParticipationCreatedGlobalEvent event) { + LocalDate localBirth = event.getDemographic().getBirth(); + List birth = List.of(localBirth.getYear(), localBirth.getMonthValue(), localBirth.getDayOfMonth()); + String birthDate = formatBirthDate(birth); + Integer age = calculateAge(birth); + String ageGroup = calculateAgeGroup(age); + + List answers = event.getAnswers().stream() + .map(answer -> new ParticipationResponses.Answer( + answer.getQuestionId(), answer.getChoiceIds(), answer.getResponseText() + )).toList(); + + return new ParticipationResponses( + event.getParticipationId(), + event.getSurveyId(), + event.getUserId(), + event.getDemographic().getGender(), + birthDate, + age, + ageGroup, + event.getCompletedAt().atZone(java.time.ZoneId.systemDefault()).toInstant(), + answers + ); + } + + private String formatBirthDate(List birth) { + if (birth == null || birth.size() < 3) return null; + return String.format("%d-%02d-%02d", birth.get(0), birth.get(1), birth.get(2)); + } + + private Integer calculateAge(List birth) { + if (birth == null || birth.size() < 1) return null; + return LocalDate.now().getYear() - birth.get(0); + } + + private String calculateAgeGroup(Integer age) { + if (age == null) return "UNKNOWN"; + if (age < 20) return "10s"; + if (age < 30) return "20s"; + if (age < 40) return "30s"; + if (age < 50) return "40s"; + return "50s_OVER"; + } +} diff --git a/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/jpa/JpaStatisticRepository.java b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/jpa/JpaStatisticRepository.java new file mode 100644 index 000000000..9839997df --- /dev/null +++ b/statistic-module/src/main/java/com/example/surveyapi/statistic/infra/jpa/JpaStatisticRepository.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.statistic.infra.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.surveyapi.statistic.domain.statistic.Statistic; + +public interface JpaStatisticRepository extends JpaRepository { +} diff --git a/survey-module/.dockerignore b/survey-module/.dockerignore new file mode 100644 index 000000000..2d33968b3 --- /dev/null +++ b/survey-module/.dockerignore @@ -0,0 +1,64 @@ +user-module/ +project-module/ +participation-module/ +statistic-module/ +share-module/ +web-app/ + +build/ +target/ +.gradle/ +out/ +bin/ + +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo + +.DS_Store +Thumbs.db +desktop.ini + +.env +.env.* +properties.env +docker/ + +*.md +docs/ +images/ +*.png +*.jpg +*.jpeg +*.gif + +test-results/ +coverage/ +*test.properties +jacoco/ + +logs/ +*.log +*.log.* + +tmp/ +temp/ +*.tmp +*.temp + +grafana/ +prometheus/ +ecs-task-definitions/ +loadtest-package/ + +.git/ +.gitignore +.gitattributes + +.github/ +.gitlab-ci.yml +.travis.yml diff --git a/survey-module/Dockerfile b/survey-module/Dockerfile new file mode 100644 index 000000000..3723f575f --- /dev/null +++ b/survey-module/Dockerfile @@ -0,0 +1,44 @@ +FROM eclipse-temurin:17-jdk AS builder + +WORKDIR /app + +COPY gradle/ gradle/ +COPY gradlew . +COPY build.gradle . +COPY settings.gradle . + +COPY shared-kernel/build.gradle shared-kernel/ +COPY shared-kernel/src/ shared-kernel/src/ + +COPY survey-module/build.gradle survey-module/ +COPY survey-module/src/ survey-module/src/ + +RUN ./gradlew :survey-module:bootJar --no-daemon + +FROM eclipse-temurin:17-jre-alpine AS runtime + +RUN apk add --no-cache curl + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder /app/survey-module/build/libs/*.jar app.jar + +RUN chown appuser:appgroup app.jar + +USER appuser + +EXPOSE 8082 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8082/actuator/health || exit 1 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+ExitOnOutOfMemoryError", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] diff --git a/survey-module/build.gradle b/survey-module/build.gradle new file mode 100644 index 000000000..79127e5c1 --- /dev/null +++ b/survey-module/build.gradle @@ -0,0 +1,22 @@ +jar { + enabled = true + archiveClassifier = '' +} + +bootJar { + enabled = false +} + +dependencies { + implementation project(':shared-kernel') + + runtimeOnly 'org.postgresql:postgresql' + + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + testImplementation 'org.springframework.security:spring-security-test' +} \ No newline at end of file diff --git a/survey-module/docker-compose.yml b/survey-module/docker-compose.yml new file mode 100644 index 000000000..5b5a63c91 --- /dev/null +++ b/survey-module/docker-compose.yml @@ -0,0 +1,137 @@ +version: '3.8' + +services: + survey-service: + build: + context: .. + dockerfile: survey-module/Dockerfile + ports: + - "8082:8082" + environment: + - SPRING_PROFILES_ACTIVE=dev + - SERVER_PORT=8082 + - DB_HOST=postgres + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_SCHEME:-survey_db} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - MONGODB_HOST=mongodb + - MONGODB_PORT=${MONGODB_PORT:-27017} + - MONGODB_DATABASE=${MONGODB_DATABASE} + - MONGODB_USERNAME=${MONGODB_USERNAME} + - MONGODB_PASSWORD=${MONGODB_PASSWORD} + - RABBITMQ_HOST=rabbitmq + - RABBITMQ_PORT=${RABBITMQ_PORT:-5672} + - RABBITMQ_USERNAME=${RABBITMQ_USERNAME:-admin} + - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD:-admin} + - ELASTIC_URIS=http://elasticsearch:9200 + depends_on: + postgres: + condition: service_healthy + mongodb: + condition: service_healthy + rabbitmq: + condition: service_healthy + elasticsearch: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8082/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - survey-network + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: ${DB_SCHEME:-survey_db} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_SCHEME:-survey_db}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - survey-network + + mongodb: + image: mongo:7 + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD} + MONGO_INITDB_DATABASE: ${MONGODB_DATABASE} + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--eval", "db.runCommand('ping').ok", "--quiet"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - survey-network + + rabbitmq: + image: rabbitmq:3.12-management + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-admin} + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] + interval: 10s + timeout: 5s + retries: 5 + command: > + sh -c " + rabbitmq-plugins enable rabbitmq_delayed_message_exchange && + docker-entrypoint.sh rabbitmq-server + " + networks: + - survey-network + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0 + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ports: + - "9200:9200" + volumes: + - elasticsearch_data:/usr/share/elasticsearch/data + command: > + sh -c " + elasticsearch-plugin list | grep -q analysis-nori || elasticsearch-plugin install analysis-nori --batch && + /usr/local/bin/docker-entrypoint.sh + " + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + networks: + - survey-network + +volumes: + postgres_data: + mongodb_data: + rabbitmq_data: + elasticsearch_data: + +networks: + survey-network: + driver: bridge diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/api/SurveyController.java b/survey-module/src/main/java/com/example/surveyapi/survey/api/SurveyController.java new file mode 100644 index 000000000..5ae8b990f --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/api/SurveyController.java @@ -0,0 +1,93 @@ +package com.example.surveyapi.survey.api; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.survey.application.command.SurveyService; +import com.example.surveyapi.survey.application.dto.request.CreateSurveyRequest; +import com.example.surveyapi.survey.application.dto.request.UpdateSurveyRequest; +import com.example.surveyapi.global.dto.ApiResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class SurveyController { + + private final SurveyService surveyService; + + @PostMapping("/projects/{projectId}/surveys") + public ResponseEntity> create( + @PathVariable Long projectId, + @Valid @RequestBody CreateSurveyRequest request, + @AuthenticationPrincipal Long creatorId, + @RequestHeader("Authorization") String authHeader + ) { + Long surveyId = surveyService.create(authHeader, projectId, creatorId, request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success("설문 생성 성공", surveyId)); + } + + @PatchMapping("/{surveyId}/open") + public ResponseEntity> open( + @PathVariable Long surveyId, + @AuthenticationPrincipal Long creatorId, + @RequestHeader("Authorization") String authHeader + ) { + + surveyService.open(authHeader, surveyId, creatorId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("설문 시작 성공", "X")); + } + + @PatchMapping("/{surveyId}/close") + public ResponseEntity> close( + @PathVariable Long surveyId, + @AuthenticationPrincipal Long creatorId, + @RequestHeader("Authorization") String authHeader + ) { + surveyService.close(authHeader, surveyId, creatorId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("설문 종료 성공", "X")); + } + + @PutMapping("/surveys/{surveyId}") + public ResponseEntity> update( + @PathVariable Long surveyId, + @Valid @RequestBody UpdateSurveyRequest request, + @AuthenticationPrincipal Long creatorId, + @RequestHeader("Authorization") String authHeader + ) { + Long updatedSurveyId = surveyService.update(authHeader, surveyId, creatorId, request); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success("설문 수정 성공", updatedSurveyId)); + } + + @DeleteMapping("/surveys/{surveyId}") + public ResponseEntity> delete( + @PathVariable Long surveyId, + @AuthenticationPrincipal Long creatorId, + @RequestHeader("Authorization") String authHeader + ) { + Long deletedSurveyId = surveyService.delete(authHeader, surveyId, creatorId); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success("설문 삭제 성공", deletedSurveyId)); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/api/SurveyQueryController.java b/survey-module/src/main/java/com/example/surveyapi/survey/api/SurveyQueryController.java new file mode 100644 index 000000000..c0e44c95d --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/api/SurveyQueryController.java @@ -0,0 +1,65 @@ +package com.example.surveyapi.survey.api; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.survey.application.dto.response.SearchSurveyDetailResponse; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyStatusResponse; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyTitleResponse; +import com.example.surveyapi.survey.application.qeury.SurveyReadService; +import com.example.surveyapi.global.dto.ApiResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class SurveyQueryController { + + private final SurveyReadService surveyReadService; + + @GetMapping("/surveys/{surveyId}") + public ResponseEntity> getSurveyDetail( + @PathVariable Long surveyId + ) { + SearchSurveyDetailResponse surveyDetailById = surveyReadService.findSurveyDetailById(surveyId); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success("조회 성공", surveyDetailById)); + } + + @GetMapping("/projects/{projectId}/surveys") + public ResponseEntity>> getSurveyList( + @PathVariable Long projectId, + @RequestParam(required = false) Long lastSurveyId + ) { + List surveyByProjectId = surveyReadService.findSurveyByProjectId(projectId, + lastSurveyId); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success("조회 성공", surveyByProjectId)); + } + + @GetMapping("/surveys/find-surveys") + public ResponseEntity>> getSurveyList( + @RequestParam List surveyIds + ) { + List surveys = surveyReadService.findSurveys(surveyIds); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success("조회 성공", surveys)); + } + + @GetMapping("/surveys/find-status") + public ResponseEntity> getSurveyStatus( + @RequestParam String surveyStatus + ) { + SearchSurveyStatusResponse bySurveyStatus = surveyReadService.findBySurveyStatus(surveyStatus); + + return ResponseEntity.status(HttpStatus.OK).body(ApiResponse.success("조회 성공", bySurveyStatus)); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ParticipationCountDto.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ParticipationCountDto.java new file mode 100644 index 000000000..82a74bdb7 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ParticipationCountDto.java @@ -0,0 +1,20 @@ +package com.example.surveyapi.survey.application.client; + +import java.util.Map; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ParticipationCountDto { + + private Map surveyPartCounts; + + public static ParticipationCountDto of(Map surveyPartCounts) { + ParticipationCountDto dto = new ParticipationCountDto(); + dto.surveyPartCounts = surveyPartCounts; + return dto; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ParticipationPort.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ParticipationPort.java new file mode 100644 index 000000000..cc660bcce --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ParticipationPort.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.survey.application.client; + +import java.util.List; + +public interface ParticipationPort { + + ParticipationCountDto getParticipationCounts(List surveyIds); +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectPort.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectPort.java new file mode 100644 index 000000000..1f6a74aec --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectPort.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.survey.application.client; + +public interface ProjectPort { + + ProjectValidDto getProjectMembers(String authHeader, Long projectId, Long userId); + + ProjectStateDto getProjectState(String authHeader, Long projectId); +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectStateDto.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectStateDto.java new file mode 100644 index 000000000..ce47ef045 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectStateDto.java @@ -0,0 +1,32 @@ +package com.example.surveyapi.survey.application.client; + +import java.io.Serializable; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProjectStateDto implements Serializable { + + private String state; + + public static ProjectStateDto of(String state) { + ProjectStateDto dto = new ProjectStateDto(); + dto.state = state; + return dto; + } + + public boolean isClosed() { + return "CLOSED".equals(state); + } + + public boolean isInProgress() { + return "IN_PROGRESS".equals(state); + } + + public boolean isPending() { + return "PENDING".equals(state); + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectValidDto.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectValidDto.java new file mode 100644 index 000000000..37a09850f --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/client/ProjectValidDto.java @@ -0,0 +1,21 @@ +package com.example.surveyapi.survey.application.client; + +import java.io.Serializable; +import java.util.List; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ProjectValidDto implements Serializable { + + private Boolean valid; + + public static ProjectValidDto of(List projectIds, Long currentProjectId) { + ProjectValidDto dto = new ProjectValidDto(); + dto.valid = projectIds.contains(currentProjectId.intValue()); + return dto; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/command/SurveyEventOrchestrator.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/command/SurveyEventOrchestrator.java new file mode 100644 index 000000000..26fd1cf27 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/command/SurveyEventOrchestrator.java @@ -0,0 +1,149 @@ +package com.example.surveyapi.survey.application.command; + +import java.time.LocalDateTime; + +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.survey.application.event.SurveyFallbackService; +import com.example.surveyapi.survey.application.event.command.EventCommand; +import com.example.surveyapi.survey.application.event.command.EventCommandFactory; +import com.example.surveyapi.survey.application.event.outbox.OutboxEventRepository; +import com.example.surveyapi.survey.domain.dlq.OutboxEvent; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.SurveyRepository; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.event.ActivateEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SurveyEventOrchestrator { + + private final EventCommandFactory commandFactory; + private final SurveyFallbackService fallbackService; + private final SurveyRepository surveyRepository; + private final OutboxEventRepository outboxEventRepository; + + @Retryable( + retryFor = {Exception.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2.0) + ) + public void orchestrateActivateEvent(ActivateEvent activateEvent) { + log.info("설문 활성화 이벤트 오케스트레이션 시작: surveyId={}", activateEvent.getSurveyId()); + + EventCommand command = commandFactory.createActivateEventCommand(activateEvent); + executeCommand(command); + + log.info("설문 활성화 이벤트 오케스트레이션 완료: surveyId={}", activateEvent.getSurveyId()); + } + + @Retryable( + retryFor = {Exception.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2.0) + ) + public void orchestrateDelayedEvent( + Long surveyId, + Long creatorId, + String routingKey, + LocalDateTime scheduledAt + ) { + + log.info("지연 이벤트 오케스트레이션 시작: surveyId={}, routingKey={}", surveyId, routingKey); + + EventCommand command = commandFactory + .createDelayedEventCommand(surveyId, creatorId, routingKey, scheduledAt); + + executeCommand(command); + + log.info("지연 이벤트 오케스트레이션 완료: surveyId={}, routingKey={}", surveyId, routingKey); + } + + private void executeCommand(EventCommand command) { + try { + log.debug("명령 실행 시작: commandId={}", command.getCommandId()); + command.execute(); + log.debug("명령 실행 완료: commandId={}", command.getCommandId()); + } catch (Exception e) { + log.error("명령 실행 실패: commandId={}, error={}", command.getCommandId(), e.getMessage()); + command.compensate(e); + throw new RuntimeException("명령 실행 실패: " + command.getCommandId(), e); + } + } + + public void orchestrateActivateEventWithOutboxCallback(ActivateEvent activateEvent, OutboxEvent outboxEvent) { + try { + orchestrateActivateEvent(activateEvent); + + markOutboxAsPublishedAndRestoreScheduleIfNeeded(outboxEvent); + + } catch (Exception e) { + log.error("아웃박스 콜백 활성화 이벤트 실패: surveyId={}, error={}", + activateEvent.getSurveyId(), e.getMessage()); + + fallbackService.handleFinalFailure(activateEvent.getSurveyId(), e.getMessage()); + throw e; + } + } + + public void orchestrateDelayedEventWithOutboxCallback(Long surveyId, Long creatorId, + String routingKey, LocalDateTime scheduledAt, OutboxEvent outboxEvent) { + try { + orchestrateDelayedEvent(surveyId, creatorId, routingKey, scheduledAt); + + markOutboxAsPublishedAndRestoreScheduleIfNeeded(outboxEvent); + + } catch (Exception e) { + log.error("아웃박스 콜백 지연 이벤트 실패: surveyId={}, routingKey={}, error={}", + surveyId, routingKey, e.getMessage()); + + fallbackService.handleFinalFailure(surveyId, e.getMessage()); + throw e; + } + } + + private void markOutboxAsPublishedAndRestoreScheduleIfNeeded(OutboxEvent outboxEvent) { + outboxEvent.asPublish(); + outboxEventRepository.save(outboxEvent); + + LocalDateTime fiveMinutesAgo = LocalDateTime.now().minusMinutes(5); + if (outboxEvent.getCreatedAt().isAfter(fiveMinutesAgo)) { + restoreAutoScheduleMode(outboxEvent.getAggregateId()); + } + } + + private void restoreAutoScheduleMode(Long surveyId) { + try { + Survey survey = surveyRepository.findById(surveyId).orElse(null); + if (survey != null && survey.getScheduleState() == ScheduleState.MANUAL_CONTROL) { + survey.restoreAutoScheduleMode("5분 내 이벤트 발행 성공으로 자동 모드 복구"); + surveyRepository.save(survey); + + log.info("스케줄 상태 자동 모드 복구 완료: surveyId={}", surveyId); + } + } catch (Exception e) { + log.error("스케줄 상태 자동 모드 복구 실패: surveyId={}, error={}", surveyId, e.getMessage()); + } + } + + @Recover + public void recoverActivateEvent(Exception ex, ActivateEvent activateEvent) { + log.error("활성화 이벤트 최종 실패: surveyId={}, error={}", activateEvent.getSurveyId(), ex.getMessage()); + } + + @Recover + public void recoverDelayedEvent(Exception ex, Long surveyId, Long creatorId, + String routingKey, LocalDateTime scheduledAt) { + log.error("지연 이벤트 최종 실패 - 스케줄 상태를 수동으로 변경: surveyId={}, routingKey={}, error={}", + surveyId, routingKey, ex.getMessage()); + + fallbackService.handleFinalFailure(surveyId, ex.getMessage()); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/command/SurveyService.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/command/SurveyService.java new file mode 100644 index 000000000..d4865373e --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/command/SurveyService.java @@ -0,0 +1,234 @@ +package com.example.surveyapi.survey.application.command; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.survey.application.client.ProjectPort; +import com.example.surveyapi.survey.application.client.ProjectStateDto; +import com.example.surveyapi.survey.application.client.ProjectValidDto; +import com.example.surveyapi.survey.application.dto.request.CreateSurveyRequest; +import com.example.surveyapi.survey.application.dto.request.UpdateSurveyRequest; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.SurveyRepository; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SurveyService { + + private final SurveyRepository surveyRepository; + private final ProjectPort projectPort; + + @Transactional + public Long create( + String authHeader, + Long projectId, + Long creatorId, + CreateSurveyRequest request + ) { + validateProjectAccess(authHeader, projectId, creatorId); + + Survey survey = Survey.create( + projectId, creatorId, + request.getTitle(), request.getDescription(), request.getSurveyType(), + request.getSurveyDuration().toSurveyDuration(), request.getSurveyOption().toSurveyOption(), + request.getQuestions().stream().map(CreateSurveyRequest.QuestionRequest::toQuestionInfo).toList() + ); + + Survey save = surveyRepository.save(survey); + + return save.getSurveyId(); + } + + @CacheEvict(value = {"surveyDetails", "surveyInfo"}, key = "#surveyId") + @Transactional + public Long update(String authHeader, Long surveyId, Long userId, UpdateSurveyRequest request) { + Survey survey = surveyRepository.findBySurveyIdAndCreatorIdAndIsDeletedFalse(surveyId, userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + if (survey.getStatus() == SurveyStatus.IN_PROGRESS) { + throw new CustomException(CustomErrorCode.CONFLICT, "진행 중인 설문은 수정할 수 없습니다."); + } + + validateProjectAccess(authHeader, survey.getProjectId(), userId); + + Map updateFields = new HashMap<>(); + + if (request.getTitle() != null) { + updateFields.put("title", request.getTitle()); + } + if (request.getDescription() != null) { + updateFields.put("description", request.getDescription()); + } + if (request.getSurveyType() != null) { + updateFields.put("type", request.getSurveyType()); + } + if (request.getSurveyDuration() != null) { + updateFields.put("duration", request.getSurveyDuration().toSurveyDuration()); + } + if (request.getSurveyOption() != null) { + updateFields.put("option", request.getSurveyOption().toSurveyOption()); + } + if (request.getQuestions() != null) { + updateFields.put("questions", + request.getQuestions().stream().map(UpdateSurveyRequest.QuestionRequest::toQuestionInfo).toList()); + } + + survey.updateFields(updateFields); + survey.applyDurationChange(survey.getDuration(), LocalDateTime.now()); + surveyRepository.update(survey); + + return survey.getSurveyId(); + } + + @CacheEvict(value = {"surveyDetails", "surveyInfo"}, key = "#surveyId") + @Transactional + public Long delete(String authHeader, Long surveyId, Long userId) { + Survey survey = surveyRepository.findBySurveyIdAndCreatorIdAndIsDeletedFalse(surveyId, userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + if (survey.getStatus() == SurveyStatus.IN_PROGRESS) { + throw new CustomException(CustomErrorCode.CONFLICT, "진행 중인 설문은 삭제할 수 없습니다."); + } + + validateProjectAccess(authHeader, survey.getProjectId(), userId); + + surveyDeleter(survey, surveyId); + + return survey.getSurveyId(); + } + + @CacheEvict(value = {"surveyDetails", "surveyInfo"}, key = "#surveyId") + @Transactional + public void open(String authHeader, Long surveyId, Long userId) { + Survey survey = surveyRepository.findBySurveyIdAndCreatorIdAndIsDeletedFalse(surveyId, userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + if (survey.getStatus() != SurveyStatus.PREPARING) { + throw new CustomException(CustomErrorCode.INVALID_STATE_TRANSITION, "준비 중인 설문만 시작할 수 있습니다."); + } + + validateProjectMembership(authHeader, survey.getProjectId(), userId); + + surveyActivator(survey, SurveyStatus.IN_PROGRESS.name()); + } + + @CacheEvict(value = {"surveyDetails", "surveyInfo"}, key = "#surveyId") + @Transactional + public void close(String authHeader, Long surveyId, Long userId) { + Survey survey = surveyRepository.findBySurveyIdAndCreatorIdAndIsDeletedFalse(surveyId, userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + if (survey.getStatus() != SurveyStatus.IN_PROGRESS) { + throw new CustomException(CustomErrorCode.INVALID_STATE_TRANSITION, "진행 중인 설문만 종료할 수 있습니다."); + } + + validateProjectMembership(authHeader, survey.getProjectId(), userId); + + surveyActivator(survey, SurveyStatus.CLOSED.name()); + } + + private void validateProjectAccess(String authHeader, Long projectId, Long userId) { + try { + log.debug("프로젝트 접근 권한 검증 시작: projectId={}, userId={}", projectId, userId); + validateProjectState(authHeader, projectId); + validateProjectMembership(authHeader, projectId, userId); + log.debug("프로젝트 접근 권한 검증 완료: projectId={}, userId={}", projectId, userId); + } catch (Exception e) { + log.error("프로젝트 접근 권한 검증 실패: projectId={}, userId={}, error={}", + projectId, userId, e.getMessage(), e); + throw e; // 예외를 다시 던져서 상위에서 처리하도록 + } + } + + private void validateProjectMembership(String authHeader, Long projectId, Long userId) { + ProjectValidDto projectValid = projectPort.getProjectMembers(authHeader, projectId, userId); + if (!projectValid.getValid()) { + throw new CustomException(CustomErrorCode.INVALID_PERMISSION, "프로젝트에 참여하지 않은 사용자입니다."); + } + } + + private void validateProjectState(String authHeader, Long projectId) { + ProjectStateDto projectState = projectPort.getProjectState(authHeader, projectId); + if (projectState.isClosed()) { + throw new CustomException(CustomErrorCode.INVALID_PERMISSION, "프로젝트가 종료되었습니다."); + } + } + + public void surveyActivator(Survey survey, String activator) { + if (activator.equals(SurveyStatus.IN_PROGRESS.name())) { + survey.openAt(LocalDateTime.now()); + } + if (activator.equals(SurveyStatus.CLOSED.name())) { + survey.closeAt(LocalDateTime.now()); + } + surveyRepository.stateUpdate(survey); + //surveyReadSync.activateSurveyRead(survey.getSurveyId(), survey.getStatus()); + } + + public void surveyDeleter(Survey survey, Long surveyId) { + survey.delete(); + surveyRepository.delete(survey); + //surveyReadSync.deleteSurveyRead(surveyId); + } + + public void surveyDeleteForProject(Long projectId) { + List surveyOp = surveyRepository.findAllByProjectId(projectId); + + surveyOp.forEach(survey -> { + surveyDeleter(survey, survey.getSurveyId()); + }); + } + + public void processSurveyStart(Long surveyId, LocalDateTime eventScheduledAt) { + Optional surveyOp = surveyRepository.findBySurveyIdAndIsDeletedFalse(surveyId); + + if (surveyOp.isEmpty()) + return; + + Survey survey = surveyOp.get(); + if (isDifferentMinute(survey.getDuration().getStartDate(), eventScheduledAt)) { + return; + } + + if (survey.getStatus() == SurveyStatus.PREPARING) { + survey.openAt(eventScheduledAt); + surveyRepository.stateUpdate(survey); + } + } + + public void processSurveyEnd(Long surveyId, LocalDateTime eventScheduledAt) { + Optional surveyOp = surveyRepository.findBySurveyIdAndIsDeletedFalse(surveyId); + + if (surveyOp.isEmpty()) + return; + + Survey survey = surveyOp.get(); + if (isDifferentMinute(survey.getDuration().getEndDate(), eventScheduledAt)) { + return; + } + + if (survey.getStatus() == SurveyStatus.IN_PROGRESS) { + survey.closeAt(eventScheduledAt); + surveyRepository.stateUpdate(survey); + } + } + + private boolean isDifferentMinute(LocalDateTime activeDate, LocalDateTime scheduledDate) { + return !activeDate.truncatedTo(ChronoUnit.MINUTES).isEqual(scheduledDate.truncatedTo(ChronoUnit.MINUTES)); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/CreateSurveyRequest.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/CreateSurveyRequest.java new file mode 100644 index 000000000..e52a602da --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/CreateSurveyRequest.java @@ -0,0 +1,37 @@ +package com.example.surveyapi.survey.application.dto.request; + +import com.example.surveyapi.survey.domain.survey.enums.SurveyType; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class CreateSurveyRequest extends SurveyRequest { + + @NotBlank(message = "설문 제목은 필수입니다.") + @Override + public String getTitle() { + return super.getTitle(); + } + + @NotNull(message = "설문 타입은 필수입니다.") + @Override + public SurveyType getSurveyType() { + return super.getSurveyType(); + } + + @NotNull(message = "설문 기간은 필수입니다.") + @Override + public Duration getSurveyDuration() { + return super.getSurveyDuration(); + } + + @NotNull(message = "설문 옵션은 필수입니다.") + @Override + public Option getSurveyOption() { + return super.getSurveyOption(); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/SurveyRequest.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/SurveyRequest.java new file mode 100644 index 000000000..ea174c786 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/SurveyRequest.java @@ -0,0 +1,120 @@ +package com.example.surveyapi.survey.application.dto.request; + +import java.time.LocalDateTime; +import java.util.List; + +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.survey.enums.SurveyType; +import com.example.surveyapi.survey.domain.survey.vo.ChoiceInfo; +import com.example.surveyapi.survey.domain.survey.vo.QuestionInfo; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public abstract class SurveyRequest { + + protected String title; + protected String description; + protected SurveyType surveyType; + protected Duration surveyDuration; + protected Option surveyOption; + + @Valid + protected List questions; + + @AssertTrue(message = "시작 일과 종료를 입력 해야 합니다.") + public boolean isValidDuration() { + return surveyDuration != null && surveyDuration.getStartDate() != null && surveyDuration.getEndDate() != null; + } + + @AssertTrue(message = "시작 일은 종료 일보다 이전 이어야 합니다.") + public boolean isStartBeforeEnd() { + return isValidDuration() && surveyDuration.getStartDate().isBefore(surveyDuration.getEndDate()); + } + + @AssertTrue(message = "종료 일은 현재 보다 이후 여야 합니다.") + public boolean isEndAfterNow() { + return isValidDuration() && surveyDuration.getEndDate().isAfter(LocalDateTime.now()); + } + + @AssertTrue(message = "시작 일은 현재 보다 이후 여야 합니다.") + public boolean isStartAfterNow() { + return isValidDuration() && surveyDuration.getStartDate().isAfter(LocalDateTime.now()); + } + + @Getter + public static class Duration { + private LocalDateTime startDate; + private LocalDateTime endDate; + + public SurveyDuration toSurveyDuration() { + return SurveyDuration.of(startDate, endDate); + } + } + + @Getter + public static class Option { + private Boolean anonymous = false; + private Boolean allowResponseUpdate = false; + + public SurveyOption toSurveyOption() { + return SurveyOption.of(anonymous, allowResponseUpdate); + } + } + + @Getter + public static class QuestionRequest { + @NotBlank(message = "질문 내용은 필수입니다.") + private String content; + + @NotNull(message = "질문 타입은 필수입니다.") + private QuestionType questionType; + + @NotNull(message = "수정 허용 여부는 필수 입니다.") + private Boolean isRequired; + + @NotNull(message = "표시 순서는 필수입니다.") + private Integer displayOrder; + + private List choices; + + @AssertTrue(message = "다중 선택지 문항에 선택지가 없습니다.") + public boolean isValid() { + if (questionType == QuestionType.MULTIPLE_CHOICE) { + return choices != null && choices.size() > 1; + } + return true; + } + + @Getter + public static class ChoiceRequest { + @NotBlank(message = "선택지 내용은 필수입니다.") + private String content; + + @NotNull(message = "표시 순서는 필수입니다.") + private Integer choiceId; + + public ChoiceInfo toChoiceInfo() { + return ChoiceInfo.of(content, choiceId); + } + } + + public QuestionInfo toQuestionInfo() { + return QuestionInfo.of( + content, + questionType, + isRequired, + displayOrder, + choices != null ? choices.stream().map(ChoiceRequest::toChoiceInfo).toList() : List.of() + ); + } + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/UpdateSurveyRequest.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/UpdateSurveyRequest.java new file mode 100644 index 000000000..dcd2fa1d9 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/request/UpdateSurveyRequest.java @@ -0,0 +1,21 @@ +package com.example.surveyapi.survey.application.dto.request; + +import jakarta.validation.constraints.AssertTrue; +import lombok.Getter; + +@Getter +public class UpdateSurveyRequest extends SurveyRequest { + + @AssertTrue(message = "요청값이 단 한개도 입력되지 않았습니다.") + private boolean isValidRequest() { + return this.title != null || this.description != null || surveyType != null || surveyDuration != null + || this.questions != null || this.surveyOption != null; + } + + @AssertTrue(message = "설문 기간이 들어온 경우, 시작일과 종료일이 모두 입력되어야 합니다.") + private boolean isValidDurationPresence() { + if (surveyDuration == null) + return true; + return surveyDuration.getStartDate() != null && surveyDuration.getEndDate() != null; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyDetailResponse.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyDetailResponse.java new file mode 100644 index 000000000..c3fb5eb2f --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyDetailResponse.java @@ -0,0 +1,173 @@ +package com.example.surveyapi.survey.application.dto.response; + +import java.time.LocalDateTime; +import java.util.List; + +import com.example.surveyapi.survey.domain.query.SurveyReadEntity; +import com.example.surveyapi.survey.domain.query.dto.SurveyDetail; +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.question.vo.Choice; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.vo.ChoiceInfo; +import com.example.surveyapi.survey.domain.survey.vo.QuestionInfo; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SearchSurveyDetailResponse { + private Long surveyId; + private String title; + private String description; + private SurveyStatus status; + private ScheduleState scheduleState; + private Duration duration; + private Option option; + private Integer participationCount; + private List questions; + + public static SearchSurveyDetailResponse from(SurveyDetail surveyDetail, Integer count) { + SearchSurveyDetailResponse response = new SearchSurveyDetailResponse(); + response.surveyId = surveyDetail.getSurveyId(); + response.title = surveyDetail.getTitle(); + response.description = surveyDetail.getDescription(); + response.status = surveyDetail.getStatus(); + response.scheduleState = surveyDetail.getScheduleState(); + response.duration = Duration.from(surveyDetail.getDuration()); + response.option = Option.from(surveyDetail.getOption()); + response.questions = surveyDetail.getQuestions().stream() + .map(QuestionResponse::from) + .toList(); + response.participationCount = count; + return response; + } + + public static SearchSurveyDetailResponse from(SurveyReadEntity entity, Integer participationCount) { + SearchSurveyDetailResponse response = new SearchSurveyDetailResponse(); + response.surveyId = entity.getSurveyId(); + response.title = entity.getTitle(); + response.description = entity.getDescription(); + response.status = SurveyStatus.valueOf(entity.getStatus()); + response.scheduleState = ScheduleState.valueOf(entity.getScheduleState()); + response.participationCount = participationCount != null ? participationCount : entity.getParticipationCount(); + + if (entity.getOptions() != null) { + response.option = Option.from(entity.getOptions().isAnonymous(), + entity.getOptions().isAllowResponseUpdate()); + response.duration = Duration.from(entity.getOptions().getStartDate(), entity.getOptions().getEndDate()); + } + + if (entity.getQuestions() != null) { + response.questions = entity.getQuestions().stream() + .map(QuestionResponse::from) + .toList(); + } + + return response; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class Duration { + private LocalDateTime startDate; + private LocalDateTime endDate; + + public static Duration from(SurveyDuration duration) { + Duration result = new Duration(); + result.startDate = duration.getStartDate(); + result.endDate = duration.getEndDate(); + return result; + } + + public static Duration from(LocalDateTime startDate, LocalDateTime endDate) { + Duration result = new Duration(); + result.startDate = startDate; + result.endDate = endDate; + return result; + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class Option { + private boolean anonymous; + private boolean allowResponseUpdate; + + public static Option from(SurveyOption option) { + Option result = new Option(); + result.anonymous = option.isAnonymous(); + result.allowResponseUpdate = option.isAllowResponseUpdate(); + return result; + } + + public static Option from(boolean anonymous, boolean allowResponseUpdate) { + Option result = new Option(); + result.anonymous = anonymous; + result.allowResponseUpdate = allowResponseUpdate; + return result; + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class QuestionResponse { + private Long questionId; + private String content; + private QuestionType questionType; + private Boolean isRequired; + private int displayOrder; + private List choices; + + public static QuestionResponse from(QuestionInfo questionInfo) { + QuestionResponse result = new QuestionResponse(); + result.questionId = questionInfo.getQuestionId(); + result.content = questionInfo.getContent(); + result.questionType = questionInfo.getQuestionType(); + result.isRequired = questionInfo.isRequired(); + result.displayOrder = questionInfo.getDisplayOrder(); + result.choices = questionInfo.getChoices().stream() + .map(ChoiceResponse::from) + .toList(); + return result; + } + + public static QuestionResponse from(SurveyReadEntity.QuestionSummary questionSummary) { + QuestionResponse result = new QuestionResponse(); + result.questionId = questionSummary.getQuestionId(); + result.content = questionSummary.getContent(); + result.questionType = questionSummary.getQuestionType(); + result.isRequired = questionSummary.isRequired(); + result.displayOrder = questionSummary.getDisplayOrder(); + result.choices = questionSummary.getChoices().stream() + .map(ChoiceResponse::from) + .toList(); + return result; + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class ChoiceResponse { + private String content; + private Integer choiceId; + + public static ChoiceResponse from(ChoiceInfo choiceInfo) { + ChoiceResponse result = new ChoiceResponse(); + result.content = choiceInfo.getContent(); + result.choiceId = choiceInfo.getChoiceId(); + return result; + } + + public static ChoiceResponse from(Choice choice) { + ChoiceResponse result = new ChoiceResponse(); + result.content = choice.getContent(); + result.choiceId = choice.getChoiceId(); + return result; + } + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyStatusResponse.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyStatusResponse.java new file mode 100644 index 000000000..fd6156f7c --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyStatusResponse.java @@ -0,0 +1,27 @@ +package com.example.surveyapi.survey.application.dto.response; + +import java.util.List; + +import com.example.surveyapi.survey.domain.query.dto.SurveyStatusList; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SearchSurveyStatusResponse { + private List surveyIds; + + public static SearchSurveyStatusResponse from(SurveyStatusList surveyStatusList) { + SearchSurveyStatusResponse searchSurveyStatusResponse = new SearchSurveyStatusResponse(); + searchSurveyStatusResponse.surveyIds = surveyStatusList.getSurveyIds(); + return searchSurveyStatusResponse; + } + + public static SearchSurveyStatusResponse from(List surveyIds) { + SearchSurveyStatusResponse searchSurveyStatusResponse = new SearchSurveyStatusResponse(); + searchSurveyStatusResponse.surveyIds = surveyIds; + return searchSurveyStatusResponse; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyTitleResponse.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyTitleResponse.java new file mode 100644 index 000000000..c9b0e22c9 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/dto/response/SearchSurveyTitleResponse.java @@ -0,0 +1,77 @@ +package com.example.surveyapi.survey.application.dto.response; + +import java.time.LocalDateTime; + +import com.example.surveyapi.survey.domain.query.SurveyReadEntity; +import com.example.surveyapi.survey.domain.query.dto.SurveyTitle; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SearchSurveyTitleResponse { + private Long surveyId; + private String title; + private String status; + private Option option; + private Duration duration; + private Integer participationCount; + + public static SearchSurveyTitleResponse from(SurveyTitle surveyTitle, Integer count) { + SearchSurveyTitleResponse response = new SearchSurveyTitleResponse(); + response.surveyId = surveyTitle.getSurveyId(); + response.title = surveyTitle.getTitle(); + response.status = surveyTitle.getStatus().name(); + response.option = Option.from(surveyTitle.getOption().isAnonymous(), surveyTitle.getOption().isAnonymous()); + response.duration = Duration.from(surveyTitle.getDuration().getStartDate(), + surveyTitle.getDuration().getEndDate()); + response.participationCount = count; + return response; + } + + public static SearchSurveyTitleResponse from(SurveyReadEntity entity) { + SearchSurveyTitleResponse response = new SearchSurveyTitleResponse(); + response.surveyId = entity.getSurveyId(); + response.title = entity.getTitle(); + response.status = entity.getStatus(); + + if (entity.getOptions() != null) { + response.option = Option.from(entity.getOptions().isAnonymous(), + entity.getOptions().isAllowResponseUpdate()); + response.duration = Duration.from(entity.getOptions().getStartDate(), entity.getOptions().getEndDate()); + } + + response.participationCount = entity.getParticipationCount(); + return response; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class Duration { + private LocalDateTime startDate; + private LocalDateTime endDate; + + public static Duration from(LocalDateTime startDate, LocalDateTime endDate) { + Duration result = new Duration(); + result.startDate = startDate; + result.endDate = endDate; + return result; + } + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class Option { + private boolean anonymous = false; + private boolean allowResponseUpdate = false; + + public static Option from(boolean anonymous, boolean allowResponseUpdate) { + Option result = new Option(); + result.anonymous = anonymous; + result.allowResponseUpdate = allowResponseUpdate; + return result; + } + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyEventListener.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyEventListener.java new file mode 100644 index 000000000..a556b0fc1 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyEventListener.java @@ -0,0 +1,146 @@ +package com.example.surveyapi.survey.application.event; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.survey.application.event.outbox.SurveyOutboxEventService; +import com.example.surveyapi.survey.application.qeury.SurveyReadSyncPort; +import com.example.surveyapi.survey.application.qeury.dto.QuestionSyncDto; +import com.example.surveyapi.survey.application.qeury.dto.SurveySyncDto; +import com.example.surveyapi.survey.domain.survey.event.ActivateEvent; +import com.example.surveyapi.survey.domain.survey.event.CreatedEvent; +import com.example.surveyapi.survey.domain.survey.event.DeletedEvent; +import com.example.surveyapi.survey.domain.survey.event.ScheduleStateChangedEvent; +import com.example.surveyapi.survey.domain.survey.event.UpdatedEvent; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.survey.SurveyActivateEvent; +import com.example.surveyapi.global.event.survey.SurveyEndDueEvent; +import com.example.surveyapi.global.event.survey.SurveyStartDueEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SurveyEventListener { + + private final SurveyReadSyncPort surveyReadSync; + private final SurveyOutboxEventService surveyOutboxEventService; + + @EventListener + public void handle(ActivateEvent event) { + log.info("ActivateEvent 수신 - 아웃박스 저장 및 조회 테이블 동기화: surveyId={}, status={}", + event.getSurveyId(), event.getSurveyStatus()); + + SurveyActivateEvent activateEvent = new SurveyActivateEvent( + event.getSurveyId(), + event.getCreatorId(), + event.getSurveyStatus().name(), + event.getEndTime() + ); + + surveyOutboxEventService.saveActivateEvent(activateEvent); + + surveyReadSync.activateSurveyRead(event.getSurveyId(), event.getSurveyStatus()); + + log.info("ActivateEvent 처리 완료: surveyId={}", event.getSurveyId()); + } + + @EventListener + public void handle(CreatedEvent event) { + log.info("CreatedEvent 수신 - 지연이벤트 아웃박스 저장 및 읽기 동기화 처리: surveyId={}", event.getSurveyId()); + + saveDelayedEvents(event.getSurveyId(), event.getCreatorId(), + event.getDuration().getStartDate(), event.getDuration().getEndDate()); + + List questionList = event.getQuestions().stream().map(QuestionSyncDto::from).toList(); + surveyReadSync.surveyReadSync( + SurveySyncDto.from( + event.getSurveyId(), event.getProjectId(), event.getTitle(), + event.getDescription(), event.getStatus(), event.getScheduleState(), + event.getOption(), event.getDuration() + ), + questionList); + + log.info("CreatedEvent 처리 완료: surveyId={}", event.getSurveyId()); + } + + @EventListener + public void handle(UpdatedEvent event) { + log.info("UpdatedEvent 수신 - 지연이벤트 아웃박스 저장 및 읽기 동기화 처리: surveyId={}", event.getSurveyId()); + + if (event.getIsDuration()) { + saveDelayedEvents(event.getSurveyId(), event.getCreatorId(), + event.getDuration().getStartDate(), event.getDuration().getEndDate()); + } + + List questionList = event.getQuestions().stream().map(QuestionSyncDto::from).toList(); + surveyReadSync.updateSurveyRead(SurveySyncDto.from( + event.getSurveyId(), event.getProjectId(), event.getTitle(), + event.getDescription(), event.getStatus(), event.getScheduleState(), + event.getOption(), event.getDuration() + )); + surveyReadSync.questionReadSync(event.getSurveyId(), questionList); + + log.info("UpdatedEvent 처리 완료: surveyId={}", event.getSurveyId()); + } + + private void saveDelayedEvents(Long surveyId, Long creatorId, LocalDateTime startDate, LocalDateTime endDate) { + if (startDate != null) { + SurveyStartDueEvent startEvent = new SurveyStartDueEvent(surveyId, creatorId, startDate); + long delayMs = java.time.Duration.between(LocalDateTime.now(), startDate).toMillis(); + + surveyOutboxEventService.saveDelayedEvent( + startEvent, + RabbitConst.ROUTING_KEY_SURVEY_START_DUE, + delayMs, + startDate, + surveyId + ); + log.debug("설문 시작 지연 이벤트 아웃박스 저장: surveyId={}, startDate={}", surveyId, startDate); + } + + if (endDate != null) { + SurveyEndDueEvent endEvent = new SurveyEndDueEvent(surveyId, creatorId, endDate); + long delayMs = java.time.Duration.between(LocalDateTime.now(), endDate).toMillis(); + + surveyOutboxEventService.saveDelayedEvent( + endEvent, + RabbitConst.ROUTING_KEY_SURVEY_END_DUE, + delayMs, + endDate, + surveyId + ); + log.debug("설문 종료 지연 이벤트 아웃박스 저장: surveyId={}, endDate={}", surveyId, endDate); + } + } + + @EventListener + public void handle(ScheduleStateChangedEvent event) { + log.info("ScheduleStateChangedEvent 수신 - 스케줄 상태 동기화 처리: surveyId={}, scheduleState={}, reason={}", + event.getSurveyId(), event.getScheduleState(), event.getChangeReason()); + + surveyReadSync.updateScheduleState( + event.getSurveyId(), + event.getScheduleState(), + event.getSurveyStatus() + ); + + log.info("스케줄 상태 동기화 완료: surveyId={}", event.getSurveyId()); + } + + @EventListener + public void handle(DeletedEvent event) { + log.info("DeletedEvent 수신 - 조회 테이블에서 설문 삭제 처리: surveyId={}", event.getSurveyId()); + + surveyReadSync.deleteSurveyRead(event.getSurveyId()); + + log.info("설문 삭제 동기화 완료: surveyId={}", event.getSurveyId()); + } +} + + diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyEventPublisherPort.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyEventPublisherPort.java new file mode 100644 index 000000000..1bc1d78a8 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyEventPublisherPort.java @@ -0,0 +1,11 @@ +package com.example.surveyapi.survey.application.event; + +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.survey.SurveyEvent; + +public interface SurveyEventPublisherPort { + + void publish(SurveyEvent event, EventCode key); + + void publishDelayed(SurveyEvent event, String routingKey, long delayMs); +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyFallbackService.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyFallbackService.java new file mode 100644 index 000000000..50674f2ff --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/SurveyFallbackService.java @@ -0,0 +1,161 @@ +package com.example.surveyapi.survey.application.event; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.SurveyRepository; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.survey.SurveyEndDueEvent; +import com.example.surveyapi.global.event.survey.SurveyEvent; +import com.example.surveyapi.global.event.survey.SurveyStartDueEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SurveyFallbackService { + + private final SurveyRepository surveyRepository; + + @Transactional + public void handleFailedEvent(SurveyEvent event, String routingKey, String failureReason) { + try { + switch (routingKey) { + case RabbitConst.ROUTING_KEY_SURVEY_START_DUE: + handleFailedSurveyStart((SurveyStartDueEvent)event, failureReason); + break; + case RabbitConst.ROUTING_KEY_SURVEY_END_DUE: + handleFailedSurveyEnd((SurveyEndDueEvent)event, failureReason); + break; + default: + log.warn("알 수 없는 라우팅 키: {}", routingKey); + } + } catch (Exception e) { + log.error("풀백 처리 중 오류: {}", e.getMessage(), e); + handleFinalFailure(event, routingKey, failureReason, e); + } + } + + private void handleFailedSurveyStart(SurveyStartDueEvent event, String failureReason) { + Long surveyId = event.getSurveyId(); + LocalDateTime scheduledTime = event.getScheduledAt(); + LocalDateTime now = LocalDateTime.now(); + + log.error("설문 시작 이벤트 실패: surveyId={}, scheduledTime={}, reason={}", + surveyId, scheduledTime, failureReason); + + Optional surveyOpt = surveyRepository.findById(surveyId); + if (surveyOpt.isEmpty()) { + log.error("설문을 찾을 수 없음: surveyId={}", surveyId); + return; + } + + Survey survey = surveyOpt.get(); + + if (scheduledTime.isBefore(now) && survey.getStatus() == SurveyStatus.PREPARING) { + log.info("설문 시작 시간이 지났으므로 즉시 시작: surveyId={}", surveyId); + survey.applyDurationChange(survey.getDuration(), now); + surveyRepository.save(survey); + log.info("설문 시작 풀백 완료: surveyId={}", surveyId); + } else { + log.warn("설문 시작 풀백 불가: surveyId={}, status={}, scheduledTime={}", + surveyId, survey.getStatus(), scheduledTime); + } + } + + private void handleFailedSurveyEnd(SurveyEndDueEvent event, String failureReason) { + Long surveyId = event.getSurveyId(); + LocalDateTime scheduledTime = event.getScheduledAt(); + LocalDateTime now = LocalDateTime.now(); + + log.error("설문 종료 이벤트 실패: surveyId={}, scheduledTime={}, reason={}", + surveyId, scheduledTime, failureReason); + + Optional surveyOpt = surveyRepository.findById(surveyId); + if (surveyOpt.isEmpty()) { + log.error("설문을 찾을 수 없음: surveyId={}", surveyId); + return; + } + + Survey survey = surveyOpt.get(); + + // 시간이 지났다면 즉시 종료 + if (scheduledTime.isBefore(now) && survey.getStatus() == SurveyStatus.IN_PROGRESS) { + log.info("설문 종료 시간이 지났으므로 즉시 종료: surveyId={}", surveyId); + survey.applyDurationChange(survey.getDuration(), now); + surveyRepository.save(survey); + log.info("설문 종료 풀백 완료: surveyId={}", surveyId); + } else { + log.warn("설문 종료 풀백 불가: surveyId={}, status={}, scheduledTime={}", + surveyId, survey.getStatus(), scheduledTime); + } + } + + private void handleFinalFailure(SurveyEvent event, String routingKey, String originalFailureReason, + Exception fallbackException) { + Long surveyId = extractSurveyId(event); + + log.error("=== 지연이벤트 발행 최종 실패 - 관리자 개입 필요 ==="); + log.error("surveyId: {}", surveyId); + log.error("routingKey: {}", routingKey); + log.error("원본 실패 사유: {}", originalFailureReason); + log.error("Fallback 실패 사유: {}", fallbackException.getMessage(), fallbackException); + + try { + Optional surveyOpt = surveyRepository.findById(surveyId); + if (surveyOpt.isEmpty()) { + log.error("최종 실패 처리 중 설문을 찾을 수 없음: surveyId={}", surveyId); + return; + } + + Survey survey = surveyOpt.get(); + survey.changeToManualMode(); + surveyRepository.save(survey); + + } catch (Exception finalException) { + log.error("수동 모드 전환도 실패 - 관리자 개입 필요: surveyId={}, error={}", + surveyId, finalException.getMessage(), finalException); + } + } + + @Transactional + public void handleFinalFailure(Long surveyId, String failureReason) { + log.error("=== 이벤트 오케스트레이션 최종 실패 - 수동 모드로 전환 ==="); + log.error("surveyId: {}", surveyId); + log.error("실패 사유: {}", failureReason); + + try { + Optional surveyOpt = surveyRepository.findById(surveyId); + if (surveyOpt.isEmpty()) { + log.error("최종 실패 처리 중 설문을 찾을 수 없음: surveyId={}", surveyId); + return; + } + + Survey survey = surveyOpt.get(); + survey.changeToManualMode(); + surveyRepository.save(survey); + + log.info("수동 모드 전환 완료: surveyId={}", surveyId); + + } catch (Exception finalException) { + log.error("수동 모드 전환도 실패 - 관리자 개입 필요: surveyId={}, error={}", + surveyId, finalException.getMessage(), finalException); + } + } + + private Long extractSurveyId(SurveyEvent event) { + if (event instanceof SurveyStartDueEvent startEvent) { + return startEvent.getSurveyId(); + } else if (event instanceof SurveyEndDueEvent endEvent) { + return endEvent.getSurveyId(); + } + return null; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/EventCommand.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/EventCommand.java new file mode 100644 index 000000000..49bcfd06e --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/EventCommand.java @@ -0,0 +1,10 @@ +package com.example.surveyapi.survey.application.event.command; + +public interface EventCommand { + + void execute() throws Exception; + + void compensate(Exception cause); + + String getCommandId(); +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/EventCommandFactory.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/EventCommandFactory.java new file mode 100644 index 000000000..299933148 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/EventCommandFactory.java @@ -0,0 +1,48 @@ +package com.example.surveyapi.survey.application.event.command; + +import java.time.Duration; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.survey.application.event.SurveyEventPublisherPort; +import com.example.surveyapi.survey.domain.survey.event.ActivateEvent; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.survey.SurveyEndDueEvent; +import com.example.surveyapi.global.event.survey.SurveyStartDueEvent; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class EventCommandFactory { + + private final SurveyEventPublisherPort publisher; + private final ObjectMapper objectMapper; + + public EventCommand createActivateEventCommand(ActivateEvent activateEvent) { + return new PublishActivateEventCommand(publisher, objectMapper, activateEvent); + } + + public EventCommand createDelayedEventCommand( + Long surveyId, + Long creatorId, + String routingKey, + LocalDateTime scheduledAt + ) { + + long delayMs = Duration.between(LocalDateTime.now(), scheduledAt).toMillis(); + + if (RabbitConst.ROUTING_KEY_SURVEY_START_DUE.equals(routingKey)) { + SurveyStartDueEvent event = new SurveyStartDueEvent(surveyId, creatorId, scheduledAt); + return new PublishDelayedEventCommand(publisher, event, routingKey, delayMs); + } else if (RabbitConst.ROUTING_KEY_SURVEY_END_DUE.equals(routingKey)) { + SurveyEndDueEvent event = new SurveyEndDueEvent(surveyId, creatorId, scheduledAt); + return new PublishDelayedEventCommand(publisher, event, routingKey, delayMs); + } + + throw new IllegalArgumentException("지원되지 않는 라우팅 키: " + routingKey); + } + +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/PublishActivateEventCommand.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/PublishActivateEventCommand.java new file mode 100644 index 000000000..eba4109ce --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/PublishActivateEventCommand.java @@ -0,0 +1,52 @@ +package com.example.surveyapi.survey.application.event.command; + +import com.example.surveyapi.survey.application.event.SurveyEventPublisherPort; +import com.example.surveyapi.survey.domain.survey.event.ActivateEvent; +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.survey.SurveyActivateEvent; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class PublishActivateEventCommand implements EventCommand { + + private final SurveyEventPublisherPort publisher; + private final ObjectMapper objectMapper; + private final ActivateEvent activateEvent; + + @Override + public void execute() throws Exception { + try { + log.info("설문 활성화 이벤트 발행 시작: surveyId={}", activateEvent.getSurveyId()); + + SurveyActivateEvent surveyActivateEvent = objectMapper.convertValue(activateEvent, + SurveyActivateEvent.class); + publisher.publish(surveyActivateEvent, EventCode.SURVEY_ACTIVATED); + + log.info("설문 활성화 이벤트 발행 완료: surveyId={}", activateEvent.getSurveyId()); + } catch (Exception e) { + log.error("설문 활성화 이벤트 발행 실패: surveyId={}, error={}", + activateEvent.getSurveyId(), e.getMessage()); + throw e; + } + } + + @Override + public void compensate(Exception cause) { + log.warn("설문 활성화 이벤트 발행 실패 - 보상 작업 실행: surveyId={}, cause={}", + activateEvent.getSurveyId(), cause.getMessage()); + + // TODO: 필요시 보상 로직 구현 (예: 상태 롤백, 알림 등) + // 현재는 로깅만 수행 + } + + @Override + public String getCommandId() { + return "PUBLISH_ACTIVATE_" + activateEvent.getSurveyId(); + } + + +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/PublishDelayedEventCommand.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/PublishDelayedEventCommand.java new file mode 100644 index 000000000..41e397bfd --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/command/PublishDelayedEventCommand.java @@ -0,0 +1,44 @@ +package com.example.surveyapi.survey.application.event.command; + +import com.example.surveyapi.survey.application.event.SurveyEventPublisherPort; +import com.example.surveyapi.global.event.survey.SurveyEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RequiredArgsConstructor +public class PublishDelayedEventCommand implements EventCommand { + + private final SurveyEventPublisherPort publisher; + private final SurveyEvent event; + private final String routingKey; + private final long delayMs; + + @Override + public void execute() throws Exception { + try { + log.info("지연 이벤트 발행 시작: routingKey={}, delayMs={}", routingKey, delayMs); + + publisher.publishDelayed(event, routingKey, delayMs); + + log.info("지연 이벤트 발행 완료: routingKey={}", routingKey); + } catch (Exception e) { + log.error("지연 이벤트 발행 실패: routingKey={}, error={}", routingKey, e.getMessage()); + throw e; + } + } + + @Override + public void compensate(Exception ex) { + log.warn("지연 이벤트 발행 실패 - 보상 작업 실행: routingKey={}, cause={}", + routingKey, ex.getMessage()); + } + + @Override + public String getCommandId() { + return "PUBLISH_DELAYED_" + routingKey + "_" + System.currentTimeMillis(); + } + + +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/enums/OutboxEventStatus.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/enums/OutboxEventStatus.java new file mode 100644 index 000000000..c1245f04f --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/enums/OutboxEventStatus.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.survey.application.event.enums; + +public enum OutboxEventStatus { + PENDING, + SENT, + PUBLISHED, + FAILED +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/outbox/OutboxEventRepository.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/outbox/OutboxEventRepository.java new file mode 100644 index 000000000..be18f2376 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/outbox/OutboxEventRepository.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.survey.application.event.outbox; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.repository.query.Param; + +import com.example.surveyapi.survey.domain.dlq.OutboxEvent; + +public interface OutboxEventRepository { + + void save(OutboxEvent event); + + void deleteAll(List events); + + List findPendingEvents(); + + List findPublishedEventsOlderThan(@Param("cutoffDate") LocalDateTime cutoffDate); +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/event/outbox/SurveyOutboxEventService.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/outbox/SurveyOutboxEventService.java new file mode 100644 index 000000000..418122895 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/event/outbox/SurveyOutboxEventService.java @@ -0,0 +1,262 @@ +package com.example.surveyapi.survey.application.event.outbox; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.survey.application.command.SurveyEventOrchestrator; +import com.example.surveyapi.survey.application.event.enums.OutboxEventStatus; +import com.example.surveyapi.survey.domain.dlq.OutboxEvent; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.event.ActivateEvent; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.survey.SurveyActivateEvent; +import com.example.surveyapi.global.event.survey.SurveyEndDueEvent; +import com.example.surveyapi.global.event.survey.SurveyStartDueEvent; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SurveyOutboxEventService { + + private final OutboxEventRepository outboxEventRepository; + private final SurveyEventOrchestrator surveyEventOrchestrator; + private final ObjectMapper objectMapper; + private final RabbitTemplate rabbitTemplate; + + @Transactional + public void saveActivateEvent(SurveyActivateEvent activateEvent) { + saveEvent( + "Survey", + activateEvent.getSurveyId(), + "SurveyActivated", + activateEvent, + RabbitConst.ROUTING_KEY_SURVEY_ACTIVE, + RabbitConst.EXCHANGE_NAME + ); + } + + @Transactional + public void saveDelayedEvent( + Object event, String routingKey, long delayMs, LocalDateTime scheduledAt, Long surveyId + ) { + saveDelayedEvent( + "Survey", + surveyId, + "SurveyDelayed", + event, + routingKey, + RabbitConst.DELAYED_EXCHANGE_NAME, + delayMs, + scheduledAt + ); + } + + @Transactional + public void saveEvent( + String aggregateType, Long aggregateId, String eventType, + Object eventData, String routingKey, String exchangeName + ) { + try { + String serializedData = objectMapper.writeValueAsString(eventData); + OutboxEvent outboxEvent = OutboxEvent.create( + aggregateType, aggregateId, eventType, serializedData, routingKey, exchangeName + ); + outboxEventRepository.save(outboxEvent); + + log.debug("Survey Outbox 이벤트 저장: aggregateId={}, eventType={}", aggregateId, eventType); + } catch (JsonProcessingException e) { + log.error("Survey 이벤트 직렬화 실패: aggregateId={}, eventType={}, error={}", + aggregateId, eventType, e.getMessage()); + throw new CustomException(CustomErrorCode.SERVER_ERROR, "Survey 이벤트 직렬화 실패 message = " + e.getMessage()); + } + } + + @Transactional + public void saveDelayedEvent( + String aggregateType, + Long aggregateId, + String eventType, + Object eventData, + String routingKey, + String exchangeName, + long delayMs, + LocalDateTime scheduledAt + ) { + try { + String serializedData = objectMapper.writeValueAsString(eventData); + OutboxEvent outboxEvent = OutboxEvent.createDelayed( + aggregateType, aggregateId, eventType, serializedData, + routingKey, exchangeName, delayMs, scheduledAt + ); + outboxEventRepository.save(outboxEvent); + + log.debug("Survey 지연 Outbox 이벤트 저장: aggregateId={}, eventType={}, scheduledAt={}", + aggregateId, eventType, scheduledAt); + } catch (JsonProcessingException e) { + log.error("Survey 지연 이벤트 직렬화 실패: aggregateId={}, eventType={}, error={}", + aggregateId, eventType, e.getMessage()); + throw new CustomException(CustomErrorCode.SERVER_ERROR, "Survey 지연 이벤트 직렬화 실패 message = " + e.getMessage()); + } + } + + @Scheduled(fixedDelay = 5000) + @Transactional + public void processSurveyOutboxEvents() { + try { + log.info("Survey Outbox 이벤트 처리 시작"); + + List pendingEvents = outboxEventRepository.findPendingEvents() + .stream() + .filter(event -> "Survey".equals(event.getAggregateType())) + .toList(); + + if (pendingEvents.isEmpty()) { + log.info("처리할 Survey Outbox 이벤트가 없습니다."); + return; + } + + // 5분 이상된 PENDING 이벤트를 FAILED로 변경 + markExpiredEventsAsFailed(pendingEvents); + + // 모든 PENDING 이벤트를 오케스트레이터로 위임 + int delegatedCount = 0; + for (OutboxEvent event : pendingEvents) { + if (event.getStatus() == OutboxEventStatus.PENDING) { + try { + delegateToOrchestrator(event); + delegatedCount++; + log.info("오케스트레이터로 위임 완료: id={}, eventType={}", + event.getOutboxEventId(), event.getEventType()); + } catch (Exception e) { + log.error("오케스트레이터 위임 실패: id={}, eventType={}, error={}", + event.getOutboxEventId(), event.getEventType(), e.getMessage()); + } + } + } + + log.info("Survey Outbox 이벤트 처리 완료: 총 이벤트={}, 위임 완료={}", + pendingEvents.size(), delegatedCount); + + } catch (Exception e) { + log.error("Survey Outbox 이벤트 처리 중 예외 발생", e); + } + } + + /** + * 5분 이상된 PENDING 이벤트를 FAILED로 변경 + */ + private void markExpiredEventsAsFailed(List events) { + LocalDateTime fiveMinutesAgo = LocalDateTime.now().minusMinutes(5); + + for (OutboxEvent event : events) { + if (event.getStatus() == OutboxEventStatus.PENDING && event.getCreatedAt().isBefore(fiveMinutesAgo)) { + + event.asFailed("5분 시간 초과로 만료됨"); + outboxEventRepository.save(event); + + log.warn("5분 시간 초과로 이벤트 만료: id={}, eventType={}, createdAt={}", + event.getOutboxEventId(), event.getEventType(), event.getCreatedAt()); + } + } + } + + /** + * PENDING 이벤트를 오케스트레이터로 위임 (기한 체크 없이) + */ + private void delegateToOrchestrator(OutboxEvent event) throws JsonProcessingException { + if ("SurveyActivated".equals(event.getEventType())) { + processSurveyActivateEvent(event); + } else if ("SurveyDelayed".equals(event.getEventType())) { + processDelayedSurveyEvent(event); + } + } + + private void processSurveyActivateEvent(OutboxEvent event) throws JsonProcessingException { + SurveyActivateEvent surveyEvent = objectMapper.readValue(event.getEventData(), SurveyActivateEvent.class); + + ActivateEvent activateEvent = new ActivateEvent( + surveyEvent.getSurveyId(), + surveyEvent.getCreatorId(), + SurveyStatus.valueOf(surveyEvent.getSurveyStatus()), + surveyEvent.getEndTime() + ); + + log.debug("오케스트레이터를 통한 설문 활성화 이벤트 처리: surveyId={}", surveyEvent.getSurveyId()); + + // 오케스트레이터에서 처리 (성공/실패에 따른 스케줄 상태 변경 포함) + surveyEventOrchestrator.orchestrateActivateEventWithOutboxCallback(activateEvent, event); + } + + private void processDelayedSurveyEvent(OutboxEvent event) throws JsonProcessingException { + if (RabbitConst.ROUTING_KEY_SURVEY_START_DUE.equals(event.getRoutingKey())) { + SurveyStartDueEvent startEvent = objectMapper.readValue(event.getEventData(), SurveyStartDueEvent.class); + log.debug("오케스트레이터를 통한 설문 시작 지연 이벤트 처리: surveyId={}", startEvent.getSurveyId()); + + // 오케스트레이터에서 처리 (성공/실패에 따른 스케줄 상태 변경 포함) + surveyEventOrchestrator.orchestrateDelayedEventWithOutboxCallback( + startEvent.getSurveyId(), + startEvent.getCreatorId(), + event.getRoutingKey(), + event.getScheduledAt(), + event + ); + } else if (RabbitConst.ROUTING_KEY_SURVEY_END_DUE.equals(event.getRoutingKey())) { + SurveyEndDueEvent endEvent = objectMapper.readValue(event.getEventData(), SurveyEndDueEvent.class); + log.debug("오케스트레이터를 통한 설문 종료 지연 이벤트 처리: surveyId={}", endEvent.getSurveyId()); + + // 오케스트레이터에서 처리 (성공/실패에 따른 스케줄 상태 변경 포함) + surveyEventOrchestrator.orchestrateDelayedEventWithOutboxCallback( + endEvent.getSurveyId(), + endEvent.getCreatorId(), + event.getRoutingKey(), + event.getScheduledAt(), + event + ); + } + } + + @Scheduled(cron = "* * 3 * * *") + @Transactional + public void cleanupPublishedSurveyEvents() { + try { + LocalDateTime cutoffDate = LocalDateTime.now().minusDays(7); + List oldEvents = outboxEventRepository.findPublishedEventsOlderThan(cutoffDate) + .stream() + .filter(event -> "Survey".equals(event.getAggregateType())) + .toList(); + + if (!oldEvents.isEmpty()) { + outboxEventRepository.deleteAll(oldEvents); + log.info("오래된 Survey Outbox 이벤트 정리 완료: 삭제된 이벤트 수={}", oldEvents.size()); + } + } catch (Exception e) { + log.error("Survey Outbox 이벤트 정리 중 오류 발생", e); + } + } + + private Object deserializeToActualEventType(String eventData, String eventType) throws JsonProcessingException { + return switch (eventType) { + case "SurveyActivated" -> objectMapper.readValue(eventData, SurveyActivateEvent.class); + case "SurveyDelayed" -> { + if (eventData.contains("startDate")) { + yield objectMapper.readValue(eventData, SurveyStartDueEvent.class); + } else { + yield objectMapper.readValue(eventData, SurveyEndDueEvent.class); + } + } + default -> throw new CustomException(CustomErrorCode.SERVER_ERROR, "지원하지 않는 이벤트 타입: " + eventType); + }; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/SurveyReadService.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/SurveyReadService.java new file mode 100644 index 000000000..a7efada03 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/SurveyReadService.java @@ -0,0 +1,82 @@ +package com.example.surveyapi.survey.application.qeury; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.survey.application.dto.response.SearchSurveyDetailResponse; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyStatusResponse; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyTitleResponse; +import com.example.surveyapi.survey.domain.query.SurveyReadEntity; +import com.example.surveyapi.survey.domain.query.SurveyReadRepository; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SurveyReadService { + + private final SurveyReadRepository surveyReadRepository; + + @Transactional(readOnly = true) + public List findSurveyByProjectId(Long projectId, Long lastSurveyId) { + List surveyReadEntities; + int pageSize = 20; + + if (lastSurveyId != null) { + surveyReadEntities = surveyReadRepository.findByProjectIdAndSurveyIdGreaterThanOrderByCreatedAtDesc( + projectId, lastSurveyId, PageRequest.of(0, pageSize)); + } else { + surveyReadEntities = surveyReadRepository.findByProjectIdOrderByCreatedAtDesc( + projectId, PageRequest.of(0, pageSize)); + } + return surveyReadEntities.stream() + .map(this::convertToSearchSurveyTitleResponse) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public SearchSurveyDetailResponse findSurveyDetailById(Long surveyId) { + SurveyReadEntity surveyReadEntity = surveyReadRepository.findBySurveyId(surveyId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + return SearchSurveyDetailResponse.from(surveyReadEntity, surveyReadEntity.getParticipationCount()); + } + + @Transactional(readOnly = true) + public List findSurveys(List surveyIds) { + List surveyReadEntities = surveyReadRepository.findBySurveyIdIn(surveyIds); + + return surveyReadEntities.stream() + .map(this::convertToSearchSurveyTitleResponse) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public SearchSurveyStatusResponse findBySurveyStatus(String surveyStatus) { + try { + SurveyStatus status = SurveyStatus.valueOf(surveyStatus); + List surveyReadEntities = surveyReadRepository.findByStatus(status.name()); + + List surveyIds = surveyReadEntities.stream() + .map(SurveyReadEntity::getSurveyId) + .collect(Collectors.toList()); + + return SearchSurveyStatusResponse.from(surveyIds); + } catch (IllegalArgumentException e) { + throw new CustomException(CustomErrorCode.STATUS_INVALID_FORMAT); + } + } + + private SearchSurveyTitleResponse convertToSearchSurveyTitleResponse(SurveyReadEntity entity) { + return SearchSurveyTitleResponse.from(entity); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/SurveyReadSyncPort.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/SurveyReadSyncPort.java new file mode 100644 index 000000000..0248cb48e --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/SurveyReadSyncPort.java @@ -0,0 +1,23 @@ +package com.example.surveyapi.survey.application.qeury; + +import java.util.List; + +import com.example.surveyapi.survey.application.qeury.dto.QuestionSyncDto; +import com.example.surveyapi.survey.application.qeury.dto.SurveySyncDto; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; + +public interface SurveyReadSyncPort { + + void surveyReadSync(SurveySyncDto dto, List questions); + + void updateSurveyRead(SurveySyncDto dto); + + void questionReadSync(Long surveyId, List dtos); + + void deleteSurveyRead(Long surveyId); + + void activateSurveyRead(Long surveyId, SurveyStatus status); + + void updateScheduleState(Long surveyId, ScheduleState scheduleState, SurveyStatus surveyStatus); +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/dto/QuestionSyncDto.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/dto/QuestionSyncDto.java new file mode 100644 index 000000000..d6ba30c70 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/dto/QuestionSyncDto.java @@ -0,0 +1,47 @@ +package com.example.surveyapi.survey.application.qeury.dto; + +import java.util.List; + +import com.example.surveyapi.survey.domain.question.Question; +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.question.vo.Choice; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class QuestionSyncDto { + Long questionId; + String content; + QuestionType type; + int displayOrder; + boolean isRequired; + List choices; + + public static QuestionSyncDto from(Question question) { + QuestionSyncDto dto = new QuestionSyncDto(); + dto.questionId = question.getQuestionId(); + dto.content = question.getContent(); + dto.type = question.getType(); + dto.displayOrder = question.getDisplayOrder(); + dto.isRequired = question.isRequired(); + dto.choices = question.getChoices().stream().map(ChoiceDto::of).toList(); + + return dto; + } + + @Getter + public static class ChoiceDto { + private String content; + private Integer choiceId; + + public static ChoiceDto of(Choice choice) { + ChoiceDto dto = new ChoiceDto(); + dto.content = choice.getContent(); + dto.choiceId = choice.getChoiceId(); + return dto; + } + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/dto/SurveySyncDto.java b/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/dto/SurveySyncDto.java new file mode 100644 index 000000000..717e80a0b --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/application/qeury/dto/SurveySyncDto.java @@ -0,0 +1,57 @@ +package com.example.surveyapi.survey.application.qeury.dto; + +import java.time.LocalDateTime; + +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SurveySyncDto { + + private Long surveyId; + private Long projectId; + private String title; + private String description; + private SurveyStatus status; + private ScheduleState scheduleState; + private SurveyOptions options; + + public static SurveySyncDto from( + Long surveyId, Long projectId, + String title, String description, + SurveyStatus status, ScheduleState scheduleState, + SurveyOption options, SurveyDuration duration + ) { + SurveySyncDto dto = new SurveySyncDto(); + dto.surveyId = surveyId; + dto.projectId = projectId; + dto.title = title; + dto.status = status; + dto.scheduleState = scheduleState; + dto.description = description; + dto.options = new SurveyOptions( + options.isAnonymous(), options.isAllowResponseUpdate(), + duration.getStartDate(), duration.getEndDate() + ); + + return dto; + } + + @Getter + @AllArgsConstructor + public static class SurveyOptions { + private boolean anonymous; + private boolean allowResponseUpdate; + private LocalDateTime startDate; + private LocalDateTime endDate; + } + +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/dlq/DeadLetterQueue.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/dlq/DeadLetterQueue.java new file mode 100644 index 000000000..bfac0a79d --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/dlq/DeadLetterQueue.java @@ -0,0 +1,66 @@ +package com.example.surveyapi.survey.domain.dlq; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.example.surveyapi.global.model.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "dead_letter_queue") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DeadLetterQueue extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "dlq_id") + private Long dlqId; + + @Column(name = "queue_name", nullable = false) + private String queueName; + + @Column(name = "routing_key", nullable = false) + private String routingKey; + + @Column(name = "message_body", columnDefinition = "TEXT", nullable = false) + @JdbcTypeCode(SqlTypes.JSON) + private String messageBody; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "failed_at", nullable = false) + private LocalDateTime failedAt; + + @Column(name = "retry_count", nullable = false) + private Integer retryCount; + + public static DeadLetterQueue create( + String queueName, + String routingKey, + String messageBody, + String errorMessage, + Integer retryCount + ) { + DeadLetterQueue dlq = new DeadLetterQueue(); + dlq.queueName = queueName; + dlq.routingKey = routingKey; + dlq.messageBody = messageBody; + dlq.errorMessage = errorMessage; + dlq.failedAt = LocalDateTime.now(); + dlq.retryCount = retryCount; + return dlq; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/dlq/OutboxEvent.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/dlq/OutboxEvent.java new file mode 100644 index 000000000..28467584d --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/dlq/OutboxEvent.java @@ -0,0 +1,133 @@ +package com.example.surveyapi.survey.domain.dlq; + +import java.time.LocalDateTime; + +import com.example.surveyapi.survey.application.event.enums.OutboxEventStatus; +import com.example.surveyapi.global.model.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "outbox_event") +@Getter +@NoArgsConstructor +public class OutboxEvent extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "outbox_event_id") + private Long outboxEventId; + + @Column(name = "aggregate_type", nullable = false) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false) + private Long aggregateId; + + @Column(name = "event_type", nullable = false) + private String eventType; + + @Column(name = "event_data", columnDefinition = "TEXT") + private String eventData; + + @Column(name = "routing_key") + private String routingKey; + + @Column(name = "exchange_name") + private String exchangeName; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private OutboxEventStatus status = OutboxEventStatus.PENDING; + + @Column(name = "retry_count") + private int retryCount = 0; + + @Column(name = "max_retry_count") + private int maxRetryCount = 3; + + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + @Column(name = "published_at") + private LocalDateTime publishedAt; + + @Column(name = "scheduled_at") + private LocalDateTime scheduledAt; + + @Column(name = "delay_ms") + private Long delayMs; + + public static OutboxEvent create( + String aggregateType, + Long aggregateId, + String eventType, + String eventData, + String routingKey, + String exchangeName + ) { + OutboxEvent outboxEvent = new OutboxEvent(); + outboxEvent.aggregateType = aggregateType; + outboxEvent.aggregateId = aggregateId; + outboxEvent.eventType = eventType; + outboxEvent.eventData = eventData; + outboxEvent.routingKey = routingKey; + outboxEvent.exchangeName = exchangeName; + return outboxEvent; + } + + public static OutboxEvent createDelayed( + String aggregateType, + Long aggregateId, + String eventType, + String eventData, + String routingKey, + String exchangeName, + long delayMs, + LocalDateTime scheduledAt + ) { + OutboxEvent outboxEvent = create(aggregateType, aggregateId, eventType, eventData, routingKey, exchangeName); + outboxEvent.delayMs = delayMs; + outboxEvent.scheduledAt = scheduledAt; + return outboxEvent; + } + + public void asPublish() { + this.status = OutboxEventStatus.PUBLISHED; + this.publishedAt = LocalDateTime.now(); + } + + public void asFailed(String errorMessage) { + this.status = OutboxEventStatus.FAILED; + this.errorMessage = errorMessage; + this.retryCount++; + + if (this.retryCount < this.maxRetryCount) { + this.status = OutboxEventStatus.PENDING; + this.nextRetryAt = LocalDateTime.now().plusMinutes((long)Math.pow(2, this.retryCount)); + } + } + + public boolean isDelayedEvent() { + return this.delayMs != null && this.scheduledAt != null; + } + + public boolean isReadyForDelivery() { + if (this.scheduledAt != null) { + return LocalDateTime.now().isAfter(this.scheduledAt); + } + return true; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/SurveyReadEntity.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/SurveyReadEntity.java new file mode 100644 index 000000000..c58f47735 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/SurveyReadEntity.java @@ -0,0 +1,112 @@ +package com.example.surveyapi.survey.domain.query; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.question.vo.Choice; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; + +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Document(collection = "survey_summaries") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class SurveyReadEntity { + + @Id + private String id; + + @Indexed + private Long surveyId; + + @Indexed + private Long projectId; + + private String title; + private String description; + private String status; + private String scheduleState; + private Integer participationCount; + + private SurveyOptions options; + @Setter + private List questions; + + public static SurveyReadEntity create( + Long surveyId, Long projectId, String title, + String description, SurveyStatus status, ScheduleState scheduleState, + Integer participationCount, SurveyOptions options + + ) { + SurveyReadEntity surveyRead = new SurveyReadEntity(); + surveyRead.surveyId = surveyId; + surveyRead.projectId = projectId; + surveyRead.title = title; + surveyRead.description = description; + surveyRead.status = status.name(); + surveyRead.scheduleState = scheduleState.name(); + surveyRead.participationCount = participationCount; + surveyRead.options = options; + + return surveyRead; + } + + public void activate(SurveyStatus status) { + this.status = status.name(); + } + + public void updateScheduleState(String scheduleState, String surveyStatus) { + this.scheduleState = scheduleState; + this.status = surveyStatus; + } + + @Getter + @AllArgsConstructor + public static class SurveyOptions { + private boolean anonymous; + private boolean allowResponseUpdate; + private LocalDateTime startDate; + private LocalDateTime endDate; + } + + @Getter + @AllArgsConstructor + public static class QuestionSummary { + private Long questionId; + private String content; + private QuestionType questionType; + private boolean isRequired; + private int displayOrder; + private List choices; + } + + public void updateParticipationCount(int participationCount) { + this.participationCount = participationCount; + } + + public void update(Long surveyId, Long projectId, String title, String description, + SurveyStatus surveyStatus, ScheduleState scheduleState, boolean anonymous, boolean allowResponseUpdate, + LocalDateTime startDate, LocalDateTime endDate) { + + this.surveyId = surveyId; + this.projectId = projectId; + this.title = title; + this.description = description; + this.status = surveyStatus.name(); + this.scheduleState = scheduleState.name(); + this.options = new SurveyOptions(anonymous, allowResponseUpdate, startDate, endDate); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/SurveyReadRepository.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/SurveyReadRepository.java new file mode 100644 index 000000000..509331e39 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/SurveyReadRepository.java @@ -0,0 +1,33 @@ +package com.example.surveyapi.survey.domain.query; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +public interface SurveyReadRepository { + List findByProjectIdOrderByCreatedAtDesc(Long projectId, Pageable pageable); + + List findByProjectIdAndSurveyIdGreaterThanOrderByCreatedAtDesc( + Long projectId, Long lastSurveyId, Pageable pageable); + + List findAll(); + + Optional findBySurveyId(Long surveyId); + + List findBySurveyIdIn(List surveyIds); + + List findByStatus(String status); + + SurveyReadEntity save(SurveyReadEntity surveyRead); + + void saveAll(List surveyReads); + + void deleteBySurveyId(Long surveyId); + + void updateStatusBySurveyId(Long surveyId, String status); + + void updateBySurveyId(SurveyReadEntity surveyRead); +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyDetail.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyDetail.java new file mode 100644 index 000000000..758d060d9 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyDetail.java @@ -0,0 +1,40 @@ +package com.example.surveyapi.survey.domain.query.dto; + +import java.util.List; + +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.vo.QuestionInfo; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SurveyDetail { + private Long surveyId; + private String title; + private String description; + private SurveyStatus status; + private ScheduleState scheduleState; + private SurveyDuration duration; + private SurveyOption option; + private List questions; + + public static SurveyDetail of(Survey survey, List questions) { + SurveyDetail detail = new SurveyDetail(); + detail.surveyId = survey.getSurveyId(); + detail.title = survey.getTitle(); + detail.description = survey.getDescription(); + detail.status = survey.getStatus(); + detail.scheduleState = survey.getScheduleState(); + detail.duration = survey.getDuration(); + detail.option = survey.getOption(); + detail.questions = questions; + return detail; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyStatusList.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyStatusList.java new file mode 100644 index 000000000..c1cf32d40 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyStatusList.java @@ -0,0 +1,17 @@ +package com.example.surveyapi.survey.domain.query.dto; + +import java.util.List; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SurveyStatusList { + private List surveyIds; + + public SurveyStatusList(List surveyIds) { + this.surveyIds = surveyIds; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyTitle.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyTitle.java new file mode 100644 index 000000000..a632e07d9 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/query/dto/SurveyTitle.java @@ -0,0 +1,31 @@ +package com.example.surveyapi.survey.domain.query.dto; + +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class SurveyTitle { + private Long surveyId; + private String title; + private SurveyOption option; + private SurveyStatus status; + private SurveyDuration duration; + + public static SurveyTitle of(Long surveyId, String title, SurveyOption option, SurveyStatus status, SurveyDuration duration) { + SurveyTitle surveyTitle = new SurveyTitle(); + surveyTitle.surveyId = surveyId; + surveyTitle.title = title; + surveyTitle.option = option; + surveyTitle.status = status; + surveyTitle.duration = duration; + return surveyTitle; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/Question.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/Question.java new file mode 100644 index 000000000..a94e19c0f --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/Question.java @@ -0,0 +1,101 @@ +package com.example.surveyapi.survey.domain.question; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.question.vo.Choice; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.vo.ChoiceInfo; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; +import com.example.surveyapi.global.model.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Entity +@Getter +@NoArgsConstructor +public class Question extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "question_id") + private Long questionId; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private QuestionType type = QuestionType.SINGLE_CHOICE; + + @Setter + @Column(name = "display_order", nullable = false) + private Integer displayOrder; + + @Column(name = "is_required", nullable = false) + private boolean isRequired; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "choices", columnDefinition = "jsonb") + private List choices = new ArrayList<>(); + + @ManyToOne( + fetch = FetchType.LAZY, + optional = false + ) + @JoinColumn( + name = "survey_id", + nullable = false + ) + private Survey survey; + + public static Question create( + Survey survey, + String content, + QuestionType type, + int displayOrder, + boolean isRequired, + List choices + ) { + Question question = new Question(); + question.survey = survey; + question.content = content; + question.type = type; + question.displayOrder = displayOrder; + question.isRequired = isRequired; + question.addChoice(choices); + + return question; + } + + private void addChoice(List choices) { + try { + List choiceList = choices.stream().map(choiceInfo -> { + return Choice.of(choiceInfo.getContent(), choiceInfo.getChoiceId()); + }).toList(); + this.choices.addAll(choiceList); + } catch (NullPointerException e) { + log.error("선택지 null {}", e.getMessage()); + throw new CustomException(CustomErrorCode.SERVER_ERROR, e.getMessage()); + } + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/enums/QuestionType.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/enums/QuestionType.java new file mode 100644 index 000000000..95242bc8f --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/enums/QuestionType.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.survey.domain.question.enums; + +public enum QuestionType { + SINGLE_CHOICE, + MULTIPLE_CHOICE, + SHORT_ANSWER, + LONG_ANSWER +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/vo/Choice.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/vo/Choice.java new file mode 100644 index 000000000..c3d099415 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/question/vo/Choice.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.survey.domain.question.vo; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class Choice { + private String content; + private Integer choiceId; + + public static Choice of(String content, int displayOrder) { + Choice choice = new Choice(); + choice.content = content; + choice.choiceId = displayOrder; + return choice; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/Survey.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/Survey.java new file mode 100644 index 000000000..5081e44af --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/Survey.java @@ -0,0 +1,213 @@ +package com.example.surveyapi.survey.domain.survey; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import com.example.surveyapi.survey.domain.question.Question; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.enums.SurveyType; +import com.example.surveyapi.survey.domain.survey.event.ActivateEvent; +import com.example.surveyapi.survey.domain.survey.event.CreatedEvent; +import com.example.surveyapi.survey.domain.survey.event.DeletedEvent; +import com.example.surveyapi.survey.domain.survey.event.ScheduleStateChangedEvent; +import com.example.surveyapi.survey.domain.survey.event.UpdatedEvent; +import com.example.surveyapi.survey.domain.survey.vo.QuestionInfo; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; +import com.example.surveyapi.global.model.AbstractRoot; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OrderBy; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Entity +@Getter +@NoArgsConstructor +public class Survey extends AbstractRoot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "survey_id") + private Long surveyId; + + @Column(name = "project_id", nullable = false) + private Long projectId; + @Column(name = "creator_id", nullable = false) + private Long creatorId; + @Column(name = "title", nullable = false) + private String title; + @Column(name = "description", columnDefinition = "TEXT") + private String description; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private SurveyType type; + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private SurveyStatus status; + @Enumerated(EnumType.STRING) + @Column(name = "schedule_state", nullable = false) + private ScheduleState scheduleState = ScheduleState.AUTO_SCHEDULED; + + @Enumerated + private SurveyOption option; + @Enumerated + private SurveyDuration duration; + + @OneToMany( + mappedBy = "survey", + cascade = { + CascadeType.PERSIST, + CascadeType.MERGE, + CascadeType.REFRESH + }, + fetch = FetchType.LAZY + ) + @OrderBy("displayOrder ASC") + private List questions = new ArrayList<>(); + + public static Survey create( + Long projectId, + Long creatorId, + String title, + String description, + SurveyType type, + SurveyDuration duration, + SurveyOption option, + List questions + ) { + Survey survey = new Survey(); + survey.projectId = projectId; + survey.creatorId = creatorId; + survey.title = title; + survey.description = description; + survey.type = type; + survey.status = SurveyStatus.PREPARING; + survey.duration = duration; + survey.option = option; + survey.addQuestion(questions); + survey.addEvent(); + + return survey; + } + + public void updateFields(Map fields) { + UpdatedEvent event = new UpdatedEvent(this, false); + fields.forEach((key, value) -> { + switch (key) { + case "title" -> this.title = (String)value; + case "description" -> this.description = (String)value; + case "type" -> this.type = (SurveyType)value; + case "duration" -> { + this.duration = (SurveyDuration)value; + event.setDuration(true); + } + case "option" -> this.option = (SurveyOption)value; + case "questions" -> { + List questions = (List)value; + this.addQuestion(questions); + } + } + }); + + registerEvent(event); + } + + public void delete() { + this.status = SurveyStatus.DELETED; + this.duration = SurveyDuration.of(this.duration.getStartDate(), LocalDateTime.now()); + this.isDeleted = true; + removeQuestions(); + registerEvent(new DeletedEvent(this)); + } + + private void addQuestion(List questions) { + try { + List questionList = questions.stream().map(questionInfo -> { + return Question.create( + this, + questionInfo.getContent(), questionInfo.getQuestionType(), + questionInfo.getDisplayOrder(), questionInfo.isRequired(), + questionInfo.getChoices()); + }).toList(); + this.questions.addAll(questionList); + } catch (NullPointerException e) { + log.error("질문 null {}", e.getMessage()); + throw new CustomException(CustomErrorCode.SERVER_ERROR, e.getMessage()); + } + } + + private void removeQuestions() { + this.questions.forEach(Question::delete); + } + + private void addEvent() { + registerEvent(new CreatedEvent(this)); + } + + public void applyDurationChange(SurveyDuration newDuration, LocalDateTime now) { + this.duration = newDuration; + + LocalDateTime startAt = this.duration.getStartDate(); + LocalDateTime endAt = this.duration.getEndDate(); + + if (startAt != null && startAt.isBefore(now) && this.status == SurveyStatus.PREPARING) { + openAt(startAt); + } + + if (endAt != null && endAt.isBefore(now)) { + if (this.status == SurveyStatus.IN_PROGRESS) { + closeAt(endAt); + } else if (this.status == SurveyStatus.PREPARING) { + openAt(startAt != null ? startAt : now); + closeAt(endAt); + } + } + } + + public void openAt(LocalDateTime startedAt) { + this.status = SurveyStatus.IN_PROGRESS; + this.duration = SurveyDuration.of(startedAt, this.duration.getEndDate()); + registerEvent(new ActivateEvent(this.surveyId, this.creatorId, this.status, this.duration.getEndDate())); + } + + public void closeAt(LocalDateTime endedAt) { + this.status = SurveyStatus.CLOSED; + this.duration = SurveyDuration.of(this.duration.getStartDate(), endedAt); + registerEvent(new ActivateEvent(this.surveyId, this.creatorId, this.status, this.duration.getEndDate())); + } + + public void changeToManualMode() { + changeToManualMode("폴백 처리로 인한 수동 모드 전환"); + } + + public void changeToManualMode(String reason) { + this.scheduleState = ScheduleState.MANUAL_CONTROL; + registerEvent(new ScheduleStateChangedEvent(this.surveyId, this.creatorId, + this.scheduleState, this.status, reason)); + } + + public void restoreAutoScheduleMode(String reason) { + this.scheduleState = ScheduleState.AUTO_SCHEDULED; + registerEvent(new ScheduleStateChangedEvent(this.surveyId, this.creatorId, + this.scheduleState, this.status, reason)); + log.info("스케줄 상태가 자동 모드로 복구됨: surveyId={}, reason={}", this.surveyId, reason); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/SurveyRepository.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/SurveyRepository.java new file mode 100644 index 000000000..d1caf0758 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/SurveyRepository.java @@ -0,0 +1,24 @@ +package com.example.surveyapi.survey.domain.survey; + +import java.util.List; +import java.util.Optional; + +public interface SurveyRepository { + Survey save(Survey survey); + + void delete(Survey survey); + + void update(Survey survey); + + void stateUpdate(Survey survey); + + void hardDelete(Survey survey); + + Optional findBySurveyIdAndIsDeletedFalse(Long surveyId); + + Optional findBySurveyIdAndCreatorIdAndIsDeletedFalse(Long surveyId, Long creatorId); + + Optional findById(Long surveyId); + + List findAllByProjectId(Long projectId); +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/ScheduleState.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/ScheduleState.java new file mode 100644 index 000000000..8e38f7d59 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/ScheduleState.java @@ -0,0 +1,7 @@ +package com.example.surveyapi.survey.domain.survey.enums; + + +public enum ScheduleState { + + AUTO_SCHEDULED, MANUAL_CONTROL, SCHEDULE_FAILED, IMMEDIATE_START +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/SurveyStatus.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/SurveyStatus.java new file mode 100644 index 000000000..d0a232550 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/SurveyStatus.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.survey.domain.survey.enums; + +public enum SurveyStatus { + PREPARING, IN_PROGRESS, CLOSED, DELETED +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/SurveyType.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/SurveyType.java new file mode 100644 index 000000000..8ddd137dd --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/enums/SurveyType.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.survey.domain.survey.enums; + +public enum SurveyType { + SURVEY, VOTE +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/ActivateEvent.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/ActivateEvent.java new file mode 100644 index 000000000..139bfb4d9 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/ActivateEvent.java @@ -0,0 +1,22 @@ +package com.example.surveyapi.survey.domain.survey.event; + +import java.time.LocalDateTime; + +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; + +import lombok.Getter; + +@Getter +public class ActivateEvent { + private Long surveyId; + private Long creatorId; + private SurveyStatus surveyStatus; + private LocalDateTime endTime; + + public ActivateEvent(Long surveyId, Long creatorId, SurveyStatus surveyStatus, LocalDateTime endTime) { + this.surveyId = surveyId; + this.creatorId = creatorId; + this.surveyStatus = surveyStatus; + this.endTime = endTime; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/CreatedEvent.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/CreatedEvent.java new file mode 100644 index 000000000..f377c2d58 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/CreatedEvent.java @@ -0,0 +1,58 @@ +package com.example.surveyapi.survey.domain.survey.event; + +import java.util.List; + +import com.example.surveyapi.survey.domain.question.Question; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; + +public class CreatedEvent { + Survey survey; + + public CreatedEvent(Survey survey) { + this.survey = survey; + } + + public Long getSurveyId() { + return survey.getSurveyId(); + } + + public Long getProjectId() { + return survey.getProjectId(); + } + + public Long getCreatorId() { + return survey.getCreatorId(); + } + + public String getTitle() { + return survey.getTitle(); + } + + public String getDescription() { + return survey.getDescription(); + } + + public SurveyStatus getStatus() { + return survey.getStatus(); + } + + public ScheduleState getScheduleState() { + return survey.getScheduleState(); + } + + public SurveyOption getOption() { + return survey.getOption(); + } + + public SurveyDuration getDuration() { + return survey.getDuration(); + } + + public List getQuestions() { + return survey.getQuestions(); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/DeletedEvent.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/DeletedEvent.java new file mode 100644 index 000000000..245e5350c --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/DeletedEvent.java @@ -0,0 +1,26 @@ +package com.example.surveyapi.survey.domain.survey.event; + +import com.example.surveyapi.survey.domain.survey.Survey; + +/** + * 설문 삭제 이벤트 + */ +public class DeletedEvent { + private final Survey survey; + + public DeletedEvent(Survey survey) { + this.survey = survey; + } + + public Long getSurveyId() { + return survey.getSurveyId(); + } + + public Long getCreatorId() { + return survey.getCreatorId(); + } + + public Survey getSurvey() { + return survey; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/ScheduleStateChangedEvent.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/ScheduleStateChangedEvent.java new file mode 100644 index 000000000..c85cdd613 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/ScheduleStateChangedEvent.java @@ -0,0 +1,42 @@ +package com.example.surveyapi.survey.domain.survey.event; + +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; + +public class ScheduleStateChangedEvent { + private final Long surveyId; + private final Long creatorId; + private final ScheduleState scheduleState; + private final SurveyStatus surveyStatus; + private final String changeReason; + + public ScheduleStateChangedEvent(Long surveyId, Long creatorId, ScheduleState scheduleState, + SurveyStatus surveyStatus, String changeReason + ) { + this.surveyId = surveyId; + this.creatorId = creatorId; + this.scheduleState = scheduleState; + this.surveyStatus = surveyStatus; + this.changeReason = changeReason; + } + + public Long getSurveyId() { + return surveyId; + } + + public Long getCreatorId() { + return creatorId; + } + + public ScheduleState getScheduleState() { + return scheduleState; + } + + public SurveyStatus getSurveyStatus() { + return surveyStatus; + } + + public String getChangeReason() { + return changeReason; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/UpdatedEvent.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/UpdatedEvent.java new file mode 100644 index 000000000..1fabce500 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/event/UpdatedEvent.java @@ -0,0 +1,69 @@ +package com.example.surveyapi.survey.domain.survey.event; + +import java.util.List; + +import com.example.surveyapi.survey.domain.question.Question; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; + +import lombok.Getter; + +@Getter +public class UpdatedEvent { + + private Survey survey; + + private Boolean isDuration; + + public UpdatedEvent(Survey survey, Boolean isDuration) { + this.survey = survey; + this.isDuration = isDuration; + } + + public Long getSurveyId() { + return survey.getSurveyId(); + } + + public Long getProjectId() { + return survey.getProjectId(); + } + + public Long getCreatorId() { + return survey.getCreatorId(); + } + + public String getTitle() { + return survey.getTitle(); + } + + public String getDescription() { + return survey.getDescription(); + } + + public SurveyStatus getStatus() { + return survey.getStatus(); + } + + public ScheduleState getScheduleState() { + return survey.getScheduleState(); + } + + public SurveyOption getOption() { + return survey.getOption(); + } + + public SurveyDuration getDuration() { + return survey.getDuration(); + } + + public List getQuestions() { + return survey.getQuestions(); + } + + public void setDuration(Boolean duration) { + isDuration = duration; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/ChoiceInfo.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/ChoiceInfo.java new file mode 100644 index 000000000..56126fc3b --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/ChoiceInfo.java @@ -0,0 +1,19 @@ +package com.example.surveyapi.survey.domain.survey.vo; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ChoiceInfo { + private String content; + private Integer choiceId; + + public static ChoiceInfo of(String content, int displayOrder) { + ChoiceInfo choiceInfo = new ChoiceInfo(); + choiceInfo.content = content; + choiceInfo.choiceId = displayOrder; + return choiceInfo; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/QuestionInfo.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/QuestionInfo.java new file mode 100644 index 000000000..306dd50eb --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/QuestionInfo.java @@ -0,0 +1,52 @@ +package com.example.surveyapi.survey.domain.survey.vo; + +import java.util.List; + +import com.example.surveyapi.survey.domain.question.enums.QuestionType; + +import jakarta.validation.constraints.AssertTrue; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class QuestionInfo { + private Long questionId; + private String content; + private QuestionType questionType; + private boolean isRequired; + private int displayOrder; + private List choices; + + public static QuestionInfo of(Long questionId, String content, QuestionType questionType, boolean isRequired, + int displayOrder, List choices) { + QuestionInfo questionInfo = new QuestionInfo(); + questionInfo.questionId = questionId; + questionInfo.content = content; + questionInfo.questionType = questionType; + questionInfo.isRequired = isRequired; + questionInfo.displayOrder = displayOrder; + questionInfo.choices = choices; + return questionInfo; + } + + public static QuestionInfo of(String content, QuestionType questionType, boolean isRequired, + int displayOrder, List choices) { + QuestionInfo questionInfo = new QuestionInfo(); + questionInfo.content = content; + questionInfo.questionType = questionType; + questionInfo.isRequired = isRequired; + questionInfo.displayOrder = displayOrder; + questionInfo.choices = choices; + return questionInfo; + } + + @AssertTrue(message = "다중 선택지 문항에 선택지가 없습니다.") + public boolean isValid() { + if (questionType == QuestionType.MULTIPLE_CHOICE) { + return choices != null && choices.size() > 1; + } + return true; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/SurveyDuration.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/SurveyDuration.java new file mode 100644 index 000000000..e77b70c4c --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/SurveyDuration.java @@ -0,0 +1,27 @@ +package com.example.surveyapi.survey.domain.survey.vo; + +import java.time.LocalDateTime; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Embeddable +public class SurveyDuration { + + @Column(name = "start_at", nullable = true) + private LocalDateTime startDate; + + @Column(name = "end_at", nullable = true) + private LocalDateTime endDate; + + public static SurveyDuration of(LocalDateTime startDate, LocalDateTime endDate) { + SurveyDuration duration = new SurveyDuration(); + duration.startDate = startDate; + duration.endDate = endDate; + return duration; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/SurveyOption.java b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/SurveyOption.java new file mode 100644 index 000000000..109fda1c3 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/domain/survey/vo/SurveyOption.java @@ -0,0 +1,24 @@ +package com.example.surveyapi.survey.domain.survey.vo; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@Embeddable +public class SurveyOption { + @Column(name = "anonymous", nullable = false) + private boolean anonymous; + + @Column(name = "allow_response_update", nullable = false) + private boolean allowResponseUpdate; + + public static SurveyOption of(boolean anonymous, boolean allowResponseUpdate) { + SurveyOption option = new SurveyOption(); + option.anonymous = anonymous; + option.allowResponseUpdate = allowResponseUpdate; + return option; + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/adapter/ParticipationAdapter.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/adapter/ParticipationAdapter.java new file mode 100644 index 000000000..84dccb557 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/adapter/ParticipationAdapter.java @@ -0,0 +1,36 @@ +package com.example.surveyapi.survey.infra.adapter; + +import java.util.List; +import java.util.Map; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.survey.application.client.ParticipationCountDto; +import com.example.surveyapi.survey.application.client.ParticipationPort; +import com.example.surveyapi.global.client.ParticipationApiClient; +import com.example.surveyapi.global.dto.ExternalApiResponse; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component("surveyParticipationAdapter") +@RequiredArgsConstructor +public class ParticipationAdapter implements ParticipationPort { + + private final ParticipationApiClient participationApiClient; + + @Value("${jwt.statistic.token}") + private String serviceToken; + + @Override + public ParticipationCountDto getParticipationCounts(List surveyIds) { + ExternalApiResponse participationCounts = participationApiClient.getParticipationCounts(surveyIds); + + @SuppressWarnings("unchecked") + Map rawData = (Map)participationCounts.getData(); + + return ParticipationCountDto.of(rawData); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/adapter/ProjectAdapter.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/adapter/ProjectAdapter.java new file mode 100644 index 000000000..8cee8bf86 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/adapter/ProjectAdapter.java @@ -0,0 +1,109 @@ +package com.example.surveyapi.survey.infra.adapter; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.survey.application.client.ProjectPort; +import com.example.surveyapi.survey.application.client.ProjectStateDto; +import com.example.surveyapi.survey.application.client.ProjectValidDto; +import com.example.surveyapi.global.client.ProjectApiClient; +import com.example.surveyapi.global.dto.ExternalApiResponse; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component("surveyProjectAdapter") +@RequiredArgsConstructor +public class ProjectAdapter implements ProjectPort { + + private final ProjectApiClient projectClient; + + @Override + @Cacheable(value = "projectMemberCache", key = "#projectId + '_' + #userId") + public ProjectValidDto getProjectMembers(String authHeader, Long projectId, Long userId) { + try { + log.debug("프로젝트 멤버 조회 시작: projectId={}, userId={}", projectId, userId); + + ExternalApiResponse projectMembers = projectClient.getProjectMembers(authHeader); + log.debug("외부 API 응답 받음: success={}, message={}", projectMembers.isSuccess(), projectMembers.getMessage()); + + if (!projectMembers.isSuccess()) { + log.warn("프로젝트 멤버 조회 실패: {}", projectMembers.getMessage()); + throw new CustomException(CustomErrorCode.NOT_FOUND_PROJECT); + } + + Object rawData = projectMembers.getData(); + log.debug("응답 데이터 타입: {}", rawData != null ? rawData.getClass().getSimpleName() : "null"); + + if (rawData == null) { + throw new CustomException(CustomErrorCode.SERVER_ERROR, "외부 API 응답 데이터가 없습니다."); + } + + List> data = (List>)rawData; + log.debug("변환된 데이터 크기: {}", data.size()); + + List projectIds = data.stream() + .map(map -> { + Object projectIdObj = map.get("projectId"); + log.debug("프로젝트 ID 객체: {}, 타입: {}", projectIdObj, + projectIdObj != null ? projectIdObj.getClass().getSimpleName() : "null"); + return (Integer)projectIdObj; + }) + .toList(); + + log.debug("추출된 프로젝트 IDs: {}", projectIds); + return ProjectValidDto.of(projectIds, projectId); + + } catch (Exception e) { + log.error("프로젝트 멤버 조회 중 오류: projectId={}, userId={}, error={}", + projectId, userId, e.getMessage(), e); + throw e; + } + } + + @Override + @Cacheable(value = "projectStateCache", key = "#projectId") + public ProjectStateDto getProjectState(String authHeader, Long projectId) { + try { + log.debug("프로젝트 상태 조회 시작: projectId={}", projectId); + + ExternalApiResponse projectState = projectClient.getProjectState(authHeader, projectId); + log.debug("외부 API 응답 받음: success={}, message={}", projectState.isSuccess(), projectState.getMessage()); + + if (!projectState.isSuccess()) { + log.warn("프로젝트 상태 조회 실패: {}", projectState.getMessage()); + throw new CustomException(CustomErrorCode.NOT_FOUND_PROJECT); + } + + Object rawData = projectState.getData(); + log.debug("응답 데이터 타입: {}", rawData != null ? rawData.getClass().getSimpleName() : "null"); + + if (rawData == null) { + throw new CustomException(CustomErrorCode.SERVER_ERROR, "외부 API 응답 데이터가 없습니다."); + } + + Map data = (Map)rawData; + log.debug("데이터 키들: {}", data.keySet()); + + String state = Optional.ofNullable(data.get("state")) + .filter(stateObj -> stateObj instanceof String) + .map(stateObj -> (String)stateObj) + .orElseThrow(() -> new CustomException(CustomErrorCode.SERVER_ERROR, + "state 필드가 없거나 String 타입이 아닙니다.")); + + log.debug("추출된 프로젝트 상태: {}", state); + return ProjectStateDto.of(state); + + } catch (Exception e) { + log.error("프로젝트 상태 조회 중 오류: projectId={}, error={}", projectId, e.getMessage(), e); + throw e; + } + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/OutBoxJpaRepository.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/OutBoxJpaRepository.java new file mode 100644 index 000000000..55d671085 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/OutBoxJpaRepository.java @@ -0,0 +1,26 @@ +package com.example.surveyapi.survey.infra.event; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.example.surveyapi.survey.domain.dlq.OutboxEvent; + +public interface OutBoxJpaRepository extends JpaRepository { + + @Query("SELECT o FROM OutboxEvent o WHERE o.status = 'PENDING' " + + "AND (o.nextRetryAt IS NULL OR o.nextRetryAt <= :now) " + + "ORDER BY o.createdAt ASC") + List findEventsToProcess(@Param("now") LocalDateTime now); + + @Query("SELECT o FROM OutboxEvent o WHERE o.status = 'PENDING' " + + "ORDER BY o.createdAt ASC") + List findPendingEvents(); + + @Query("SELECT o FROM OutboxEvent o WHERE o.status = 'PUBLISHED' " + + "AND o.publishedAt < :cutoffDate") + List findPublishedEventsOlderThan(@Param("cutoffDate") LocalDateTime cutoffDate); +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/OutboxRepositoryImpl.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/OutboxRepositoryImpl.java new file mode 100644 index 000000000..e26496f49 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/OutboxRepositoryImpl.java @@ -0,0 +1,38 @@ +package com.example.surveyapi.survey.infra.event; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.survey.application.event.outbox.OutboxEventRepository; +import com.example.surveyapi.survey.domain.dlq.OutboxEvent; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class OutboxRepositoryImpl implements OutboxEventRepository { + + private final OutBoxJpaRepository jpaRepository; + + @Override + public void save(OutboxEvent event) { + jpaRepository.save(event); + } + + @Override + public void deleteAll(List events) { + jpaRepository.deleteAll(events); + } + + @Override + public List findPendingEvents() { + return jpaRepository.findPendingEvents(); + } + + @Override + public List findPublishedEventsOlderThan(LocalDateTime cutoffDate) { + return jpaRepository.findPublishedEventsOlderThan(cutoffDate); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/SurveyConsumer.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/SurveyConsumer.java new file mode 100644 index 000000000..568376cfc --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/SurveyConsumer.java @@ -0,0 +1,100 @@ +package com.example.surveyapi.survey.infra.event; + +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.survey.application.command.SurveyService; +import com.example.surveyapi.survey.domain.dlq.DeadLetterQueue; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.project.ProjectDeletedEvent; +import com.example.surveyapi.global.event.survey.SurveyEndDueEvent; +import com.example.surveyapi.global.event.survey.SurveyStartDueEvent; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@RabbitListener( + queues = RabbitConst.QUEUE_NAME_SURVEY +) +public class SurveyConsumer { + + private final SurveyService surveyService; + private final ObjectMapper objectMapper; + + @RabbitHandler + public void handleProjectClosed(ProjectDeletedEvent event) { + try { + log.info("이벤트 수신"); + surveyService.surveyDeleteForProject(event.getProjectId()); + } catch (Exception e) { + log.error(e.getMessage(), e); + } + } + + @RabbitHandler + @Transactional + @Retryable( + retryFor = {Exception.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2.0) + ) + public void handleSurveyStart(SurveyStartDueEvent event) { + try { + log.info("SurveyStartDueEvent 수신: surveyId={}, scheduledAt={}", event.getSurveyId(), + event.getScheduledAt()); + surveyService.processSurveyStart(event.getSurveyId(), event.getScheduledAt()); + } catch (Exception e) { + log.error("SurveyStartDueEvent 처리 실패: surveyId={}, error={}", event.getSurveyId(), e.getMessage()); + throw e; + } + } + + @RabbitHandler + @Transactional + @Retryable( + retryFor = {Exception.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 1000, multiplier = 2.0) + ) + public void handleSurveyEnd(SurveyEndDueEvent event) { + try { + log.info("SurveyEndDueEvent 수신: surveyId={}, scheduledAt={}", event.getSurveyId(), event.getScheduledAt()); + surveyService.processSurveyEnd(event.getSurveyId(), event.getScheduledAt()); + } catch (Exception e) { + log.error("SurveyEndDueEvent 처리 실패: surveyId={}, error={}", event.getSurveyId(), e.getMessage()); + throw e; + } + } + + @Recover + public void recoverSurveyStart(Exception ex, SurveyStartDueEvent event) { + log.error("SurveyStartDueEvent 최종 실패 - DLQ 저장: surveyId={}, error={}", event.getSurveyId(), ex.getMessage()); + + saveToDlq("survey.start.due", "SurveyStartDueEvent", event, ex.getMessage(), 3); + } + + @Recover + public void recoverSurveyEnd(Exception ex, SurveyEndDueEvent event) { + log.error("SurveyEndDueEvent 최종 실패 - DLQ 저장: surveyId={}, error={}", event.getSurveyId(), ex.getMessage()); + saveToDlq("survey.end.due", "SurveyEndDueEvent", event, ex.getMessage(), 3); + } + + private void saveToDlq(String routingKey, String queueName, Object event, String errorMessage, Integer retryCount) { + try { + String messageBody = objectMapper.writeValueAsString(event); + DeadLetterQueue dlq = DeadLetterQueue.create(queueName, routingKey, messageBody, errorMessage, retryCount); + log.info("DLQ 저장 완료: routingKey={}, queueName={}", routingKey, queueName); + } catch (Exception e) { + log.error("DLQ 저장 실패: routingKey={}, error={}", routingKey, e.getMessage()); + } + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/SurveyEventPublisher.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/SurveyEventPublisher.java new file mode 100644 index 000000000..aa7060706 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/event/SurveyEventPublisher.java @@ -0,0 +1,40 @@ +package com.example.surveyapi.survey.infra.event; + +import java.util.HashMap; +import java.util.Map; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Service; + +import com.example.surveyapi.survey.application.event.SurveyEventPublisherPort; +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.survey.SurveyEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SurveyEventPublisher implements SurveyEventPublisherPort { + + private final RabbitTemplate rabbitTemplate; + + @Override + public void publish(SurveyEvent event, EventCode key) { + if (key.equals(EventCode.SURVEY_ACTIVATED)) { + rabbitTemplate.convertAndSend(RabbitConst.EXCHANGE_NAME, RabbitConst.ROUTING_KEY_SURVEY_ACTIVE, event); + } + } + + @Override + public void publishDelayed(SurveyEvent event, String routingKey, long delayMs) { + Map headers = new HashMap<>(); + headers.put("x-delay", delayMs); + rabbitTemplate.convertAndSend(RabbitConst.DELAYED_EXCHANGE_NAME, routingKey, event, message -> { + message.getMessageProperties().getHeaders().putAll(headers); + return message; + }); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyDataReconciliation.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyDataReconciliation.java new file mode 100644 index 000000000..1b34b227e --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyDataReconciliation.java @@ -0,0 +1,164 @@ +package com.example.surveyapi.survey.infra.query; + +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.survey.application.qeury.SurveyReadSyncPort; +import com.example.surveyapi.survey.domain.query.SurveyReadEntity; +import com.example.surveyapi.survey.domain.query.SurveyReadRepository; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.SurveyRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SurveyDataReconciliation { + + private final SurveyRepository surveyRepository; + private final SurveyReadRepository surveyReadRepository; + private final SurveyReadSyncPort surveyReadSync; + + @Scheduled(cron = "0 */10 * * * ?") + @Transactional(readOnly = true) + public void reconcileScheduleStates() { + try { + log.debug("스케줄 상태 정합성 보정 시작"); + + List readEntities = surveyReadRepository.findAll(); + if (readEntities.isEmpty()) { + log.debug("보정할 설문이 없습니다."); + return; + } + + int inconsistentCount = 0; + int correctedCount = 0; + + for (SurveyReadEntity readEntity : readEntities) { + try { + var surveyOpt = surveyRepository.findById(readEntity.getSurveyId()); + if (surveyOpt.isEmpty()) { + log.warn("PostgreSQL에 없는 설문: surveyId={}", readEntity.getSurveyId()); + continue; + } + + Survey survey = surveyOpt.get(); + + // 삭제된 설문 처리 + if (survey.getStatus().name().equals("DELETED")) { + inconsistentCount++; + log.warn("삭제된 설문이 MongoDB에 여전히 존재: surveyId={}", survey.getSurveyId()); + + try { + surveyReadSync.deleteSurveyRead(survey.getSurveyId()); + correctedCount++; + log.info("삭제된 설문 MongoDB에서 제거 완료: surveyId={}", survey.getSurveyId()); + } catch (Exception e) { + log.error("삭제된 설문 제거 실패: surveyId={}, error={}", survey.getSurveyId(), e.getMessage()); + } + } + // 일반적인 상태 불일치 처리 + else if (isStateInconsistent(survey, readEntity)) { + inconsistentCount++; + log.warn( + "상태 불일치 발견: surveyId={}, PostgreSQL=[status={}, scheduleState={}], MongoDB=[status={}, scheduleState={}]", + survey.getSurveyId(), + survey.getStatus(), survey.getScheduleState(), + readEntity.getStatus(), readEntity.getScheduleState()); + + try { + surveyReadSync.updateScheduleState( + survey.getSurveyId(), + survey.getScheduleState(), + survey.getStatus() + ); + correctedCount++; + log.info("상태 불일치 보정 완료: surveyId={}", survey.getSurveyId()); + } catch (Exception e) { + log.error("상태 보정 실패: surveyId={}, error={}", survey.getSurveyId(), e.getMessage()); + } + } + } catch (Exception e) { + log.error("설문 상태 검사 중 오류: surveyId={}, error={}", readEntity.getSurveyId(), e.getMessage()); + } + } + + if (inconsistentCount > 0) { + log.info("스케줄 상태 정합성 보정 완료: 불일치={}, 보정성공={}", + inconsistentCount, correctedCount); + } else { + log.debug("스케줄 상태 정합성 보정 완료: 모든 데이터 일치"); + } + + } catch (Exception e) { + log.error("스케줄 상태 정합성 보정 중 오류 발생", e); + } + } + + @Scheduled(cron = "0 0 2 * * ?") + @Transactional(readOnly = true) + public void generateDataConsistencyReport() { + try { + log.info("데이터 정합성 리포트 생성 시작"); + + List readEntities = surveyReadRepository.findAll(); + if (readEntities.isEmpty()) { + log.info("MongoDB에 설문 데이터가 없습니다."); + return; + } + + int totalMongoSurveys = readEntities.size(); + int orphanedInMongo = 0; + int validSurveys = 0; + + for (SurveyReadEntity readEntity : readEntities) { + try { + var surveyOpt = surveyRepository.findById(readEntity.getSurveyId()); + if (surveyOpt.isEmpty()) { + orphanedInMongo++; + log.warn("PostgreSQL에 없는 고아 데이터 발견: surveyId={}, status={}, scheduleState={}", + readEntity.getSurveyId(), readEntity.getStatus(), readEntity.getScheduleState()); + } else { + validSurveys++; + } + } catch (Exception e) { + log.error("데이터 정합성 검사 중 오류: surveyId={}, error={}", + readEntity.getSurveyId(), e.getMessage()); + } + } + + log.info("=== 데이터 정합성 리포트 ==="); + log.info("MongoDB 총 설문 수: {}", totalMongoSurveys); + log.info("유효한 설문 수: {}", validSurveys); + log.info("고아 데이터 수: {}", orphanedInMongo); + log.info("정합성 비율: {:.2f}%", (double)validSurveys / totalMongoSurveys * 100); + + if (orphanedInMongo > 0) { + log.error("=== 고아 데이터 발견 - 관리자 확인 필요 ==="); + log.error("총 {} 개의 고아 데이터가 MongoDB에 존재합니다.", orphanedInMongo); + } else { + log.info("모든 MongoDB 데이터가 PostgreSQL과 일치합니다."); + } + + } catch (Exception e) { + log.error("데이터 정합성 리포트 생성 중 오류 발생", e); + } + } + + private boolean isStateInconsistent(Survey survey, SurveyReadEntity readEntity) { + if (!survey.getScheduleState().name().equals(readEntity.getScheduleState())) { + return true; + } + + if (!survey.getStatus().name().equals(readEntity.getStatus())) { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyReadRepositoryImpl.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyReadRepositoryImpl.java new file mode 100644 index 000000000..28f69d968 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyReadRepositoryImpl.java @@ -0,0 +1,123 @@ +package com.example.surveyapi.survey.infra.query; + +import static org.springframework.data.domain.Sort.Direction.*; +import static org.springframework.data.domain.Sort.*; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.mongodb.core.BulkOperations; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.survey.domain.query.SurveyReadEntity; +import com.example.surveyapi.survey.domain.query.SurveyReadRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class SurveyReadRepositoryImpl implements SurveyReadRepository { + + private final MongoTemplate mongoTemplate; + + @Override + public List findByProjectIdOrderByCreatedAtDesc(Long projectId, Pageable pageable) { + Query query = new Query(Criteria.where("projectId").is(projectId)); + query.with(by(DESC, "surveyId")); + query.limit(pageable.getPageSize()); + return mongoTemplate.find(query, SurveyReadEntity.class); + } + + @Override + public List findByProjectIdAndSurveyIdGreaterThanOrderByCreatedAtDesc( + Long projectId, Long lastSurveyId, Pageable pageable + ) { + Query query = new Query(Criteria.where("projectId").is(projectId)); + query.addCriteria(Criteria.where("surveyId").gt(lastSurveyId)); + query.with(by(DESC, "surveyId")); + query.limit(pageable.getPageSize()); + return mongoTemplate.find(query, SurveyReadEntity.class); + } + + @Override + public List findAll() { + return mongoTemplate.findAll(SurveyReadEntity.class); + } + + @Override + public Optional findBySurveyId(Long surveyId) { + Query query = new Query(Criteria.where("surveyId").is(surveyId)); + return Optional.ofNullable(mongoTemplate.findOne(query, SurveyReadEntity.class)); + } + + @Override + public List findBySurveyIdIn(List surveyIds) { + Query query = new Query(Criteria.where("surveyId").in(surveyIds)); + return mongoTemplate.find(query, SurveyReadEntity.class); + } + + @Override + public List findByStatus(String status) { + Query query = new Query(Criteria.where("status").is(status)); + return mongoTemplate.find(query, SurveyReadEntity.class); + } + + @Override + public SurveyReadEntity save(SurveyReadEntity surveyRead) { + return mongoTemplate.save(surveyRead); + } + + @Override + public void saveAll(List surveyReads) { + if (surveyReads.isEmpty()) { + return; + } + + BulkOperations bulkOps = mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, SurveyReadEntity.class); + + for (SurveyReadEntity surveyRead : surveyReads) { + Query query = new Query(Criteria.where("surveyId").is(surveyRead.getSurveyId())); + Update update = new Update() + .set("title", surveyRead.getTitle()) + .set("description", surveyRead.getDescription()) + .set("status", surveyRead.getStatus()) + .set("participationCount", surveyRead.getParticipationCount()) + .set("options", surveyRead.getOptions()) + .set("questions", surveyRead.getQuestions()); + + bulkOps.upsert(query, update); + } + + bulkOps.execute(); + } + + @Override + public void deleteBySurveyId(Long surveyId) { + Query query = new Query(Criteria.where("surveyId").is(surveyId)); + mongoTemplate.remove(query, SurveyReadEntity.class); + } + + @Override + public void updateStatusBySurveyId(Long surveyId, String status) { + Query query = new Query(Criteria.where("surveyId").is(surveyId)); + Update update = new Update().set("status", status); + mongoTemplate.updateFirst(query, update, SurveyReadEntity.class); + } + + @Override + public void updateBySurveyId(SurveyReadEntity surveyRead) { + Query query = new Query(Criteria.where("surveyId").is(surveyRead.getSurveyId())); + Update update = new Update() + .set("title", surveyRead.getTitle()) + .set("description", surveyRead.getDescription()) + .set("status", surveyRead.getStatus()) + .set("options", surveyRead.getOptions()) + .set("questions", surveyRead.getQuestions()); + mongoTemplate.updateFirst(query, update, SurveyReadEntity.class); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyReadSync.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyReadSync.java new file mode 100644 index 000000000..5e81881f3 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/query/SurveyReadSync.java @@ -0,0 +1,169 @@ +package com.example.surveyapi.survey.infra.query; + +import java.util.List; +import java.util.Map; + +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.survey.application.client.ParticipationPort; +import com.example.surveyapi.survey.application.qeury.SurveyReadSyncPort; +import com.example.surveyapi.survey.application.qeury.dto.QuestionSyncDto; +import com.example.surveyapi.survey.application.qeury.dto.SurveySyncDto; +import com.example.surveyapi.survey.domain.query.SurveyReadEntity; +import com.example.surveyapi.survey.domain.query.SurveyReadRepository; +import com.example.surveyapi.survey.domain.question.vo.Choice; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SurveyReadSync implements SurveyReadSyncPort { + + private final SurveyReadRepository surveyReadRepository; + private final ParticipationPort partPort; + + @Async + @Transactional + public void surveyReadSync(SurveySyncDto dto, List questions) { + try { + log.debug("설문 조회 테이블 동기화 시작"); + + SurveySyncDto.SurveyOptions options = dto.getOptions(); + SurveyReadEntity.SurveyOptions surveyOptions = new SurveyReadEntity.SurveyOptions(options.isAnonymous(), + options.isAllowResponseUpdate(), options.getStartDate(), options.getEndDate()); + + SurveyReadEntity surveyRead = SurveyReadEntity.create( + dto.getSurveyId(), dto.getProjectId(), dto.getTitle(), + dto.getDescription(), dto.getStatus(), dto.getScheduleState(), + 0, surveyOptions + ); + + SurveyReadEntity save = surveyReadRepository.save(surveyRead); + log.debug("설문 조회 테이블 동기화 종료"); + + questionReadSync(save.getSurveyId(), questions); + + } catch (Exception e) { + log.error("설문 조회 테이블 동기화 실패 {}", e.getMessage()); + } + } + + @Async + @Transactional + public void updateSurveyRead(SurveySyncDto dto) { + try { + log.debug("설문 조회 테이블 업데이트 시작"); + + SurveyReadEntity surveyRead = surveyReadRepository.findBySurveyId(dto.getSurveyId()) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + surveyRead.update(dto.getSurveyId(), dto.getProjectId(), dto.getTitle(), dto.getDescription(), + dto.getStatus(), dto.getScheduleState(), dto.getOptions().isAnonymous(), + dto.getOptions().isAllowResponseUpdate(), dto.getOptions().getStartDate(), + dto.getOptions().getEndDate()); + + surveyReadRepository.save(surveyRead); + log.debug("설문 조회 테이블 업데이트 종료"); + + } catch (Exception e) { + log.error("설문 조회 테이블 업데이트 실패 {}", e.getMessage()); + } + } + + @Async + @Transactional + public void questionReadSync(Long surveyId, List dtos) { + log.debug("설문 조회 테이블 질문 동기화 시작"); + + try { + SurveyReadEntity surveyRead = surveyReadRepository.findBySurveyId(surveyId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + surveyRead.setQuestions(dtos.stream().map(dto -> { + return new SurveyReadEntity.QuestionSummary( + dto.getQuestionId(), dto.getContent(), dto.getType(), + dto.isRequired(), dto.getDisplayOrder(), + dto.getChoices() + .stream() + .map(choiceDto -> Choice.of(choiceDto.getContent(), choiceDto.getChoiceId())) + .toList() + ); + }).toList()); + surveyReadRepository.save(surveyRead); + log.debug("설문 조회 테이블 질문 동기화 종료"); + } catch (Exception e) { + log.error("설문 조회 테이블 질문 동기화 실패: surveyId={}, error={}", surveyId, e.getMessage()); + } + } + + @Async + @Transactional + public void deleteSurveyRead(Long surveyId) { + surveyReadRepository.deleteBySurveyId(surveyId); + } + + @Async + @Transactional + public void activateSurveyRead(Long surveyId, SurveyStatus status) { + SurveyReadEntity surveyRead = surveyReadRepository.findBySurveyId(surveyId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + if (status.equals(SurveyStatus.DELETED)) { + surveyReadRepository.deleteBySurveyId(surveyId); + } else { + surveyRead.activate(status); + surveyReadRepository.save(surveyRead); + } + } + + @Async + @Transactional + public void updateScheduleState(Long surveyId, ScheduleState scheduleState, SurveyStatus surveyStatus) { + try { + log.debug("설문 스케줄 상태 업데이트 시작: surveyId={}, scheduleState={}, surveyStatus={}", + surveyId, scheduleState, surveyStatus); + + SurveyReadEntity surveyRead = surveyReadRepository.findBySurveyId(surveyId) + .orElseThrow(() -> new CustomException(CustomErrorCode.NOT_FOUND_SURVEY)); + + surveyRead.updateScheduleState(scheduleState.name(), surveyStatus.name()); + surveyReadRepository.save(surveyRead); + + log.debug("설문 스케줄 상태 업데이트 완료: surveyId={}", surveyId); + } catch (Exception e) { + log.error("설문 스케줄 상태 업데이트 실패: surveyId={}, error={}", surveyId, e.getMessage()); + } + } + + @Scheduled(fixedRate = 300000) + public void batchParticipationCountSync() { + log.debug("참여자 수 조회 시작"); + List surveys = surveyReadRepository.findAll(); + + if (surveys.isEmpty()) { + log.debug("동기화할 설문이 없습니다."); + return; + } + + List surveyIds = surveys.stream().map(SurveyReadEntity::getSurveyId).toList(); + + Map surveyPartCounts = partPort.getParticipationCounts(surveyIds).getSurveyPartCounts(); + + surveys.forEach(survey -> { + if (surveyPartCounts.containsKey(survey.getSurveyId().toString())) { + survey.updateParticipationCount(surveyPartCounts.get(survey.getSurveyId().toString())); + } + }); + + surveyReadRepository.saveAll(surveys); + } +} diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/survey/SurveyRepositoryImpl.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/survey/SurveyRepositoryImpl.java new file mode 100644 index 000000000..497fee7f8 --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/survey/SurveyRepositoryImpl.java @@ -0,0 +1,66 @@ +package com.example.surveyapi.survey.infra.survey; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.SurveyRepository; +import com.example.surveyapi.survey.infra.survey.jpa.JpaSurveyRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class SurveyRepositoryImpl implements SurveyRepository { + + private final JpaSurveyRepository jpaRepository; + + @Override + public Survey save(Survey survey) { + return jpaRepository.save(survey); + } + + @Override + public void delete(Survey survey) { + jpaRepository.save(survey); + } + + @Override + public void update(Survey survey) { + jpaRepository.save(survey); + } + + @Override + public void stateUpdate(Survey survey) { + jpaRepository.save(survey); + } + + @Override + public void hardDelete(Survey survey) { + jpaRepository.delete(survey); + } + + @Override + public Optional findBySurveyIdAndIsDeletedFalse(Long surveyId) { + return jpaRepository.findBySurveyIdAndIsDeletedFalse(surveyId); + } + + @Override + public Optional findBySurveyIdAndCreatorIdAndIsDeletedFalse(Long surveyId, Long creatorId) { + return jpaRepository.findBySurveyIdAndCreatorIdAndIsDeletedFalse(surveyId, creatorId); + } + + @Override + public Optional findById(Long surveyId) { + return jpaRepository.findById(surveyId); + } + + @Override + public List findAllByProjectId(Long projectId) { + return jpaRepository.findAllByProjectId(projectId); + } +} + + diff --git a/survey-module/src/main/java/com/example/surveyapi/survey/infra/survey/jpa/JpaSurveyRepository.java b/survey-module/src/main/java/com/example/surveyapi/survey/infra/survey/jpa/JpaSurveyRepository.java new file mode 100644 index 000000000..cc3ce5e4e --- /dev/null +++ b/survey-module/src/main/java/com/example/surveyapi/survey/infra/survey/jpa/JpaSurveyRepository.java @@ -0,0 +1,18 @@ +package com.example.surveyapi.survey.infra.survey.jpa; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.surveyapi.survey.domain.survey.Survey; + +public interface JpaSurveyRepository extends JpaRepository { + Optional findBySurveyIdAndCreatorId(Long surveyId, Long creatorId); + + Optional findBySurveyIdAndCreatorIdAndIsDeletedFalse(Long surveyId, Long creatorId); + + Optional findBySurveyIdAndIsDeletedFalse(Long surveyId); + + List findAllByProjectId(Long projectId); +} diff --git a/src/test/java/com/example/surveyapi/domain/survey/TestPortConfiguration.java b/survey-module/src/test/java/com/example/surveyapi/survey/TestPortConfiguration.java similarity index 76% rename from src/test/java/com/example/surveyapi/domain/survey/TestPortConfiguration.java rename to survey-module/src/test/java/com/example/surveyapi/survey/TestPortConfiguration.java index 94c10d2af..2f4b766b2 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/TestPortConfiguration.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/TestPortConfiguration.java @@ -1,12 +1,12 @@ -package com.example.surveyapi.domain.survey; +package com.example.surveyapi.survey; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; -import com.example.surveyapi.domain.survey.application.client.ProjectPort; -import com.example.surveyapi.domain.survey.application.client.ProjectStateDto; -import com.example.surveyapi.domain.survey.application.client.ProjectValidDto; +import com.example.surveyapi.survey.application.client.ProjectPort; +import com.example.surveyapi.survey.application.client.ProjectStateDto; +import com.example.surveyapi.survey.application.client.ProjectValidDto; import java.util.List; diff --git a/src/test/java/com/example/surveyapi/domain/survey/api/SurveyControllerTest.java b/survey-module/src/test/java/com/example/surveyapi/survey/api/SurveyControllerTest.java similarity index 94% rename from src/test/java/com/example/surveyapi/domain/survey/api/SurveyControllerTest.java rename to survey-module/src/test/java/com/example/surveyapi/survey/api/SurveyControllerTest.java index fcfbc1cd1..b3d168b09 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/api/SurveyControllerTest.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/api/SurveyControllerTest.java @@ -1,14 +1,13 @@ -package com.example.surveyapi.domain.survey.api; - -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import java.time.LocalDateTime; -import java.util.List; - +package com.example.surveyapi.survey.api; + +import com.example.surveyapi.survey.application.command.SurveyService; +import com.example.surveyapi.survey.application.dto.request.CreateSurveyRequest; +import com.example.surveyapi.survey.application.dto.request.UpdateSurveyRequest; +import com.example.surveyapi.survey.application.dto.request.SurveyRequest; +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.survey.enums.SurveyType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/example/surveyapi/domain/survey/api/SurveyQueryControllerTest.java b/survey-module/src/test/java/com/example/surveyapi/survey/api/SurveyQueryControllerTest.java similarity index 90% rename from src/test/java/com/example/surveyapi/domain/survey/api/SurveyQueryControllerTest.java rename to survey-module/src/test/java/com/example/surveyapi/survey/api/SurveyQueryControllerTest.java index f57bb5934..6983e5c38 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/api/SurveyQueryControllerTest.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/api/SurveyQueryControllerTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.survey.api; +package com.example.surveyapi.survey.api; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -18,16 +18,16 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import com.example.surveyapi.domain.survey.application.dto.response.SearchSurveyDetailResponse; -import com.example.surveyapi.domain.survey.application.dto.response.SearchSurveyStatusResponse; -import com.example.surveyapi.domain.survey.application.dto.response.SearchSurveyTitleResponse; -import com.example.surveyapi.domain.survey.application.qeury.SurveyReadService; -import com.example.surveyapi.domain.survey.domain.query.SurveyReadEntity; -import com.example.surveyapi.domain.survey.domain.survey.enums.ScheduleState; -import com.example.surveyapi.domain.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.application.qeury.SurveyReadService; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyDetailResponse; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyStatusResponse; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyTitleResponse; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.enums.ScheduleState; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; import com.example.surveyapi.global.exception.GlobalExceptionHandler; +import com.example.surveyapi.survey.domain.query.SurveyReadEntity; @ExtendWith(MockitoExtension.class) class SurveyQueryControllerTest { diff --git a/src/test/java/com/example/surveyapi/domain/survey/application/IntegrationTestBase.java b/survey-module/src/test/java/com/example/surveyapi/survey/application/IntegrationTestBase.java similarity index 97% rename from src/test/java/com/example/surveyapi/domain/survey/application/IntegrationTestBase.java rename to survey-module/src/test/java/com/example/surveyapi/survey/application/IntegrationTestBase.java index 35c5f0264..8df177954 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/application/IntegrationTestBase.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/application/IntegrationTestBase.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.survey.application; +package com.example.surveyapi.survey.application; import com.example.surveyapi.config.TestMockConfig; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/com/example/surveyapi/domain/survey/application/SurveyIntegrationTest.java b/survey-module/src/test/java/com/example/surveyapi/survey/application/SurveyIntegrationTest.java similarity index 89% rename from src/test/java/com/example/surveyapi/domain/survey/application/SurveyIntegrationTest.java rename to survey-module/src/test/java/com/example/surveyapi/survey/application/SurveyIntegrationTest.java index 11b70796a..914320023 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/application/SurveyIntegrationTest.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/application/SurveyIntegrationTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.survey.application; +package com.example.surveyapi.survey.application; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -18,23 +18,23 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.data.mongodb.core.MongoTemplate; -import com.example.surveyapi.domain.survey.application.client.ProjectPort; -import com.example.surveyapi.domain.survey.application.client.ProjectStateDto; -import com.example.surveyapi.domain.survey.application.client.ProjectValidDto; -import com.example.surveyapi.domain.survey.application.command.SurveyService; -import com.example.surveyapi.domain.survey.application.dto.request.CreateSurveyRequest; -import com.example.surveyapi.domain.survey.application.dto.request.SurveyRequest; -import com.example.surveyapi.domain.survey.application.dto.request.UpdateSurveyRequest; -import com.example.surveyapi.domain.survey.application.dto.response.SearchSurveyDetailResponse; -import com.example.surveyapi.domain.survey.application.dto.response.SearchSurveyTitleResponse; -import com.example.surveyapi.domain.survey.application.qeury.SurveyReadService; -import com.example.surveyapi.domain.survey.domain.question.enums.QuestionType; -import com.example.surveyapi.domain.survey.domain.query.SurveyReadEntity; -import com.example.surveyapi.domain.survey.domain.query.SurveyReadRepository; -import com.example.surveyapi.domain.survey.domain.survey.Survey; -import com.example.surveyapi.domain.survey.domain.survey.SurveyRepository; -import com.example.surveyapi.domain.survey.domain.survey.enums.SurveyStatus; -import com.example.surveyapi.domain.survey.domain.survey.enums.SurveyType; +import com.example.surveyapi.survey.application.client.ProjectPort; +import com.example.surveyapi.survey.application.client.ProjectStateDto; +import com.example.surveyapi.survey.application.client.ProjectValidDto; +import com.example.surveyapi.survey.application.command.SurveyService; +import com.example.surveyapi.survey.application.dto.request.CreateSurveyRequest; +import com.example.surveyapi.survey.application.dto.request.SurveyRequest; +import com.example.surveyapi.survey.application.dto.request.UpdateSurveyRequest; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyDetailResponse; +import com.example.surveyapi.survey.application.dto.response.SearchSurveyTitleResponse; +import com.example.surveyapi.survey.application.qeury.SurveyReadService; +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.query.SurveyReadEntity; +import com.example.surveyapi.survey.domain.query.SurveyReadRepository; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.SurveyRepository; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.enums.SurveyType; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/src/test/java/com/example/surveyapi/domain/survey/application/event/SurveyFallbackServiceTest.java b/survey-module/src/test/java/com/example/surveyapi/survey/application/event/SurveyFallbackServiceTest.java similarity index 88% rename from src/test/java/com/example/surveyapi/domain/survey/application/event/SurveyFallbackServiceTest.java rename to survey-module/src/test/java/com/example/surveyapi/survey/application/event/SurveyFallbackServiceTest.java index 01da9e31a..9fc022446 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/application/event/SurveyFallbackServiceTest.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/application/event/SurveyFallbackServiceTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.survey.application.event; +package com.example.surveyapi.survey.application.event; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -13,10 +13,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.example.surveyapi.domain.survey.domain.survey.Survey; -import com.example.surveyapi.domain.survey.domain.survey.SurveyRepository; -import com.example.surveyapi.domain.survey.domain.survey.enums.SurveyStatus; -import com.example.surveyapi.domain.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.SurveyRepository; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; import com.example.surveyapi.global.event.RabbitConst; import com.example.surveyapi.global.event.survey.SurveyStartDueEvent; import com.example.surveyapi.global.event.survey.SurveyEndDueEvent; diff --git a/src/test/java/com/example/surveyapi/domain/survey/domain/question/QuestionTest.java b/survey-module/src/test/java/com/example/surveyapi/survey/domain/question/QuestionTest.java similarity index 93% rename from src/test/java/com/example/surveyapi/domain/survey/domain/question/QuestionTest.java rename to survey-module/src/test/java/com/example/surveyapi/survey/domain/question/QuestionTest.java index 9eb9054dc..0b6f2d731 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/domain/question/QuestionTest.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/domain/question/QuestionTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.survey.domain.question; +package com.example.surveyapi.survey.domain.question; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; @@ -9,12 +9,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.example.surveyapi.domain.survey.domain.question.enums.QuestionType; -import com.example.surveyapi.domain.survey.domain.survey.Survey; -import com.example.surveyapi.domain.survey.domain.survey.enums.SurveyType; -import com.example.surveyapi.domain.survey.domain.survey.vo.ChoiceInfo; -import com.example.surveyapi.domain.survey.domain.survey.vo.SurveyDuration; -import com.example.surveyapi.domain.survey.domain.survey.vo.SurveyOption; +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.survey.Survey; +import com.example.surveyapi.survey.domain.survey.enums.SurveyType; +import com.example.surveyapi.survey.domain.survey.vo.ChoiceInfo; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; diff --git a/src/test/java/com/example/surveyapi/domain/survey/domain/survey/SurveyTest.java b/survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/SurveyTest.java similarity index 95% rename from src/test/java/com/example/surveyapi/domain/survey/domain/survey/SurveyTest.java rename to survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/SurveyTest.java index 575d159d4..a5aee535b 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/domain/survey/SurveyTest.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/SurveyTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.survey.domain.survey; +package com.example.surveyapi.survey.domain.survey; import static org.assertj.core.api.Assertions.assertThat; @@ -9,12 +9,12 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.example.surveyapi.domain.survey.domain.question.enums.QuestionType; -import com.example.surveyapi.domain.survey.domain.survey.enums.SurveyStatus; -import com.example.surveyapi.domain.survey.domain.survey.enums.SurveyType; -import com.example.surveyapi.domain.survey.domain.survey.vo.QuestionInfo; -import com.example.surveyapi.domain.survey.domain.survey.vo.SurveyDuration; -import com.example.surveyapi.domain.survey.domain.survey.vo.SurveyOption; +import com.example.surveyapi.survey.domain.question.enums.QuestionType; +import com.example.surveyapi.survey.domain.survey.enums.SurveyStatus; +import com.example.surveyapi.survey.domain.survey.enums.SurveyType; +import com.example.surveyapi.survey.domain.survey.vo.QuestionInfo; +import com.example.surveyapi.survey.domain.survey.vo.SurveyDuration; +import com.example.surveyapi.survey.domain.survey.vo.SurveyOption; class SurveyTest { diff --git a/src/test/java/com/example/surveyapi/domain/survey/domain/survey/vo/SurveyDurationTest.java b/survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/vo/SurveyDurationTest.java similarity index 98% rename from src/test/java/com/example/surveyapi/domain/survey/domain/survey/vo/SurveyDurationTest.java rename to survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/vo/SurveyDurationTest.java index cbcce1745..02e6e5f70 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/domain/survey/vo/SurveyDurationTest.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/vo/SurveyDurationTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.survey.domain.survey.vo; +package com.example.surveyapi.survey.domain.survey.vo; import static org.assertj.core.api.Assertions.assertThat; diff --git a/src/test/java/com/example/surveyapi/domain/survey/domain/survey/vo/SurveyOptionTest.java b/survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/vo/SurveyOptionTest.java similarity index 97% rename from src/test/java/com/example/surveyapi/domain/survey/domain/survey/vo/SurveyOptionTest.java rename to survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/vo/SurveyOptionTest.java index baeaed342..2d1cbf777 100644 --- a/src/test/java/com/example/surveyapi/domain/survey/domain/survey/vo/SurveyOptionTest.java +++ b/survey-module/src/test/java/com/example/surveyapi/survey/domain/survey/vo/SurveyOptionTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.survey.domain.survey.vo; +package com.example.surveyapi.survey.domain.survey.vo; import static org.assertj.core.api.Assertions.assertThat; diff --git a/user-module/.dockerignore b/user-module/.dockerignore new file mode 100644 index 000000000..e791b81fc --- /dev/null +++ b/user-module/.dockerignore @@ -0,0 +1,64 @@ +survey-module/ +project-module/ +participation-module/ +statistic-module/ +share-module/ +web-app/ + +build/ +target/ +.gradle/ +out/ +bin/ + +.idea/ +.vscode/ +*.iml +*.ipr +*.iws +*.swp +*.swo + +.DS_Store +Thumbs.db +desktop.ini + +.env +.env.* +properties.env +docker/ + +*.md +docs/ +images/ +*.png +*.jpg +*.jpeg +*.gif + +test-results/ +coverage/ +*test.properties +jacoco/ + +logs/ +*.log +*.log.* + +tmp/ +temp/ +*.tmp +*.temp + +grafana/ +prometheus/ +ecs-task-definitions/ +loadtest-package/ + +.git/ +.gitignore +.gitattributes + +.github/ +.gitlab-ci.yml +.travis.yml diff --git a/user-module/Dockerfile b/user-module/Dockerfile new file mode 100644 index 000000000..c256682e9 --- /dev/null +++ b/user-module/Dockerfile @@ -0,0 +1,44 @@ +FROM eclipse-temurin:17-jdk AS builder + +WORKDIR /app + +COPY gradle/ gradle/ +COPY gradlew . +COPY build.gradle . +COPY settings.gradle . + +COPY shared-kernel/build.gradle shared-kernel/ +COPY shared-kernel/src/ shared-kernel/src/ + +COPY user-module/build.gradle user-module/ +COPY user-module/src/ user-module/src/ + +RUN ./gradlew :user-module:bootJar --no-daemon + +FROM eclipse-temurin:17-jre-alpine AS runtime + +RUN apk add --no-cache curl + +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app + +COPY --from=builder /app/user-module/build/libs/*.jar app.jar + +RUN chown appuser:appgroup app.jar + +USER appuser + +EXPOSE 8081 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8081/actuator/health || exit 1 + +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-XX:+ExitOnOutOfMemoryError", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] diff --git a/user-module/build.gradle b/user-module/build.gradle new file mode 100644 index 000000000..1848f00e4 --- /dev/null +++ b/user-module/build.gradle @@ -0,0 +1,20 @@ +jar { + enabled = true + archiveClassifier = '' +} + +bootJar { + enabled = false +} + +dependencies { + implementation project(':shared-kernel') + + runtimeOnly 'org.postgresql:postgresql' + + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:5.1.0:jakarta" + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + + testImplementation 'org.springframework.security:spring-security-test' +} diff --git a/user-module/docker-compose.yml b/user-module/docker-compose.yml new file mode 100644 index 000000000..6d304cf89 --- /dev/null +++ b/user-module/docker-compose.yml @@ -0,0 +1,111 @@ +version: '3.8' + +services: + user-service: + build: + context: .. + dockerfile: user-module/Dockerfile + ports: + - "8081:8081" + environment: + - SPRING_PROFILES_ACTIVE=dev + - SERVER_PORT=8081 + - DB_HOST=postgres + - DB_PORT=${DB_PORT:-5432} + - DB_NAME=${DB_SCHEME:-survey_db} + - DB_USERNAME=${DB_USERNAME} + - DB_PASSWORD=${DB_PASSWORD} + - REDIS_HOST=redis + - REDIS_PORT=${REDIS_PORT:-6379} + - RABBITMQ_HOST=rabbitmq + - RABBITMQ_PORT=${RABBITMQ_PORT:-5672} + - RABBITMQ_USERNAME=${RABBITMQ_USERNAME:-admin} + - RABBITMQ_PASSWORD=${RABBITMQ_PASSWORD:-admin} + - SECRET_KEY=${SECRET_KEY} + - KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID} + - KAKAO_REDIRECT_URL=${KAKAO_REDIRECT_URL} + - NAVER_CLIENT_ID=${NAVER_CLIENT_ID} + - NAVER_SECRET=${NAVER_SECRET} + - NAVER_REDIRECT_URL=${NAVER_REDIRECT_URL} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_SECRET=${GOOGLE_SECRET} + - GOOGLE_REDIRECT_URL=${GOOGLE_REDIRECT_URL} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8081/actuator/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - user-network + + postgres: + image: postgres:15-alpine + environment: + POSTGRES_DB: ${DB_SCHEME:-survey_db} + POSTGRES_USER: ${DB_USERNAME} + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d ${DB_SCHEME:-survey_db}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - user-network + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - user-network + + rabbitmq: + image: rabbitmq:3.12-management + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USERNAME:-admin} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-admin} + ports: + - "5672:5672" + - "15672:15672" + volumes: + - rabbitmq_data:/var/lib/rabbitmq + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"] + interval: 10s + timeout: 5s + retries: 5 + command: > + sh -c " + rabbitmq-plugins enable rabbitmq_delayed_message_exchange && + docker-entrypoint.sh rabbitmq-server + " + networks: + - user-network + +volumes: + postgres_data: + redis_data: + rabbitmq_data: + +networks: + user-network: + driver: bridge diff --git a/user-module/src/main/java/com/example/surveyapi/user/api/AuthController.java b/user-module/src/main/java/com/example/surveyapi/user/api/AuthController.java new file mode 100644 index 000000000..10999e51a --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/api/AuthController.java @@ -0,0 +1,83 @@ +package com.example.surveyapi.user.api; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.user.application.AuthService; +import com.example.surveyapi.user.application.dto.request.LoginRequest; +import com.example.surveyapi.user.application.dto.request.SignupRequest; +import com.example.surveyapi.user.application.dto.request.UserWithdrawRequest; +import com.example.surveyapi.user.application.dto.response.LoginResponse; +import com.example.surveyapi.user.application.dto.response.SignupResponse; +import com.example.surveyapi.global.dto.ApiResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/signup") + public ResponseEntity> signup( + @Valid @RequestBody SignupRequest request + ) { + SignupResponse signup = authService.signup(request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success("회원가입 성공", signup)); + } + + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody LoginRequest request + ) { + LoginResponse login = authService.login(request); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("로그인 성공", login)); + } + + @PostMapping("/withdraw") + public ResponseEntity> withdraw( + @Valid @RequestBody UserWithdrawRequest request, + @AuthenticationPrincipal Long userId, + @RequestHeader("Authorization") String authHeader + ) { + authService.withdraw(userId, request, authHeader); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("회원 탈퇴가 완료되었습니다.", null)); + } + + @PostMapping("/logout") + public ResponseEntity> logout( + @RequestHeader("Authorization") String authHeader, + @AuthenticationPrincipal Long userId + ) { + authService.logout(authHeader, userId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("로그아웃 되었습니다.", null)); + } + + @PostMapping("/reissue") + public ResponseEntity> reissue( + @RequestHeader("Authorization") String accessToken, + @RequestHeader("RefreshToken") String refreshToken // Bearer 까지 넣어서 + ) { + LoginResponse reissue = authService.reissue(accessToken, refreshToken); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("토큰이 재발급되었습니다.", reissue)); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/api/OAuthController.java b/user-module/src/main/java/com/example/surveyapi/user/api/OAuthController.java new file mode 100644 index 000000000..ef8e63a76 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/api/OAuthController.java @@ -0,0 +1,55 @@ +package com.example.surveyapi.user.api; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.user.application.AuthService; +import com.example.surveyapi.user.application.dto.request.SignupRequest; +import com.example.surveyapi.user.application.dto.response.LoginResponse; +import com.example.surveyapi.global.dto.ApiResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class OAuthController { + + private final AuthService authService; + + @PostMapping("/api/auth/kakao/login") + public ResponseEntity> kakaoLogin( + @RequestParam("code") String code, + @RequestBody SignupRequest request + ) { + LoginResponse login = authService.kakaoLogin(code, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("로그인 성공", login)); + } + + @PostMapping("/api/auth/naver/login") + public ResponseEntity> naverLogin( + @RequestParam("code") String code, + @RequestBody SignupRequest request + ) { + LoginResponse login = authService.naverLogin(code, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("로그인 성공", login)); + } + + @PostMapping("/api/auth/google/login") + public ResponseEntity> googleLogin( + @RequestParam("code") String code, + @RequestBody SignupRequest request + ) { + LoginResponse login = authService.googleLogin(code, request); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("로그인 성공", login)); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/api/UserController.java b/user-module/src/main/java/com/example/surveyapi/user/api/UserController.java new file mode 100644 index 000000000..c0eea900a --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/api/UserController.java @@ -0,0 +1,96 @@ +package com.example.surveyapi.user.api; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.surveyapi.user.application.dto.request.UpdateUserRequest; +import com.example.surveyapi.user.application.dto.response.UpdateUserResponse; +import com.example.surveyapi.user.application.dto.response.UserByEmailResponse; +import com.example.surveyapi.user.application.dto.response.UserGradeResponse; + +import com.example.surveyapi.user.application.dto.response.UserInfoResponse; +import com.example.surveyapi.user.application.UserService; +import com.example.surveyapi.user.application.dto.response.UserSnapShotResponse; +import com.example.surveyapi.global.dto.ApiResponse; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class UserController { + + private final UserService userService; + + @GetMapping("/api/users") + public ResponseEntity>> getUsers( + Pageable pageable + ) { + Page all = userService.getAll(pageable); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("회원 전체 조회 성공", all)); + } + + @GetMapping("/api/users/me") + public ResponseEntity> getUser( + @AuthenticationPrincipal Long userId + ) { + UserInfoResponse user = userService.getUser(userId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("회원 조회 성공", user)); + } + + @GetMapping("/api/users/grade") + public ResponseEntity> getGrade( + @AuthenticationPrincipal Long userId + ) { + UserGradeResponse success = userService.getGradeAndPoint(userId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("회원 등급 조회 성공", success)); + } + + @PatchMapping("/api/users/me") + public ResponseEntity> update( + @Valid @RequestBody UpdateUserRequest request, + @AuthenticationPrincipal Long userId + ) { + UpdateUserResponse update = userService.update(request, userId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("회원 정보 수정 성공", update)); + } + + @GetMapping("/api/users/{userId}/snapshot") + public ResponseEntity> snapshot( + @PathVariable Long userId + ) { + UserSnapShotResponse snapshot = userService.snapshot(userId); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("스냅샷 정보", snapshot)); + } + + @GetMapping("/api/users/by-email") + public ResponseEntity> Byemail( + @RequestHeader("Authorization") String authHeader, + @RequestParam("email") String email + ) { + UserByEmailResponse byEmail = userService.byEmail(email); + + return ResponseEntity.status(HttpStatus.OK) + .body(ApiResponse.success("이메일로 UserId 조회", byEmail)); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/AuthService.java b/user-module/src/main/java/com/example/surveyapi/user/application/AuthService.java new file mode 100644 index 000000000..12662793e --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/AuthService.java @@ -0,0 +1,330 @@ +package com.example.surveyapi.user.application; + +import java.time.Duration; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.user.application.client.port.OAuthPort; +import com.example.surveyapi.user.application.client.request.GoogleOAuthRequest; +import com.example.surveyapi.user.application.client.request.KakaoOAuthRequest; +import com.example.surveyapi.user.application.client.request.NaverOAuthRequest; +import com.example.surveyapi.user.application.client.response.GoogleAccessResponse; +import com.example.surveyapi.user.application.client.response.GoogleUserInfoResponse; +import com.example.surveyapi.user.application.client.response.KakaoAccessResponse; +import com.example.surveyapi.user.application.client.response.KakaoUserInfoResponse; +import com.example.surveyapi.user.application.client.response.NaverAccessResponse; +import com.example.surveyapi.user.application.client.response.NaverUserInfoResponse; +import com.example.surveyapi.user.application.dto.request.LoginRequest; +import com.example.surveyapi.user.application.dto.request.SignupRequest; +import com.example.surveyapi.user.application.dto.request.UserWithdrawRequest; +import com.example.surveyapi.user.application.dto.response.LoginResponse; +import com.example.surveyapi.user.application.dto.response.SignupResponse; +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.application.client.port.UserRedisPort; +import com.example.surveyapi.user.domain.user.UserRepository; +import com.example.surveyapi.global.auth.jwt.JwtUtil; +import com.example.surveyapi.global.auth.oauth.GoogleOAuthProperties; +import com.example.surveyapi.global.auth.oauth.KakaoOAuthProperties; +import com.example.surveyapi.global.auth.oauth.NaverOAuthProperties; +import com.example.surveyapi.global.auth.jwt.PasswordEncoder; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtUtil jwtUtil; + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final OAuthPort OAuthPort; + private final KakaoOAuthProperties kakaoOAuthProperties; + private final NaverOAuthProperties naverOAuthProperties; + private final GoogleOAuthProperties googleOAuthProperties; + private final UserRedisPort userRedisPort; + + @Transactional + public SignupResponse signup(SignupRequest request) { + User createUser = createAndSaveUser(request); + + return SignupResponse.from(createUser); + } + + @Transactional + public LoginResponse login(LoginRequest request) { + User user = userRepository.findByEmailAndIsDeletedFalse(request.getEmail()) + .orElseThrow(() -> new CustomException(CustomErrorCode.EMAIL_NOT_FOUND)); + + if (!passwordEncoder.matches(request.getPassword(), user.getAuth().getPassword())) { + throw new CustomException(CustomErrorCode.WRONG_PASSWORD); + } + + return createAccessAndSaveRefresh(user); + } + + @Transactional + public void withdraw(Long userId, UserWithdrawRequest request, String authHeader) { + + User user = userRepository.findByIdAndIsDeletedFalse(userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + if (!passwordEncoder.matches(request.getPassword(), user.getAuth().getPassword())) { + throw new CustomException(CustomErrorCode.WRONG_PASSWORD); + } + + user.delete(); + userRepository.save(user); + + addBlackLists(authHeader); + userRedisPort.delete(userId); + } + + @Transactional + public void logout(String authHeader, Long userId) { + + addBlackLists(authHeader); + + userRedisPort.delete(userId); + } + + @Transactional + public LoginResponse reissue(String authHeader, String bearerRefreshToken) { + String accessToken = jwtUtil.subStringToken(authHeader); + String refreshToken = jwtUtil.subStringToken(bearerRefreshToken); + + jwtUtil.validateToken(refreshToken); + + if (!jwtUtil.isTokenExpired(accessToken)) { + throw new CustomException(CustomErrorCode.ACCESS_TOKEN_NOT_EXPIRED); + } + + String accessTokenCheckKey = "blackListToken" + accessToken; + String accessTokenCheckResult = userRedisPort.getRedisKey(accessTokenCheckKey); + + if (accessTokenCheckResult != null) { + throw new CustomException(CustomErrorCode.INVALID_TOKEN); + } + + Claims refreshClaims = jwtUtil.extractClaims(refreshToken); + long userId = Long.parseLong(refreshClaims.getSubject()); + + User user = userRepository.findByIdAndIsDeletedFalse(userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + String refreshTokenCheckKey = "refreshToken" + userId; + String storedBearerRefreshToken = userRedisPort.getRedisKey(refreshTokenCheckKey); + + if (storedBearerRefreshToken == null) { + throw new CustomException(CustomErrorCode.NOT_FOUND_REFRESH_TOKEN); + } + + if (!refreshToken.equals(jwtUtil.subStringToken(storedBearerRefreshToken))) { + throw new CustomException(CustomErrorCode.MISMATCH_REFRESH_TOKEN); + } + + userRedisPort.delete(userId); + return createAccessAndSaveRefresh(user); + } + + @Transactional + public LoginResponse kakaoLogin(String code, SignupRequest request) { + log.info("카카오 로그인 실행"); + // 인가 코드 → 액세스 토큰 + KakaoAccessResponse kakaoAccessToken = getKakaoAccessToken(code); + log.info("액세스 토큰 발급 완료"); + + // 액세시 토큰 → 사용자 정보 (providerId) + KakaoUserInfoResponse kakaoUserInfo = getKakaoUserInfo(kakaoAccessToken.getAccess_token()); + log.info("providerId 획득"); + + String providerId = String.valueOf(kakaoUserInfo.getProviderId()); + + // 회원가입 유저인지 확인 + User user = userRepository.findByAuthProviderAndAuthProviderIdAndIsDeletedFalse(Provider.KAKAO, providerId) + .orElseGet(() -> { + User newUser = createAndSaveUser(request); + newUser.getAuth().updateProviderId(providerId); + log.info("회원가입 완료"); + return newUser; + }); + + return createAccessAndSaveRefresh(user); + } + + @Transactional + public LoginResponse naverLogin(String code, SignupRequest request) { + log.info("네이버 로그인 실행"); + NaverAccessResponse naverAccessToken = getNaverAcessToken(code); + log.info("액세스 토큰 발급 완료"); + + NaverUserInfoResponse naverUserInfo = getNaverUserInfo(naverAccessToken.getAccess_token()); + log.info("providerId 획득"); + + String providerId = String.valueOf(naverUserInfo.getResponse().getProviderId()); + + // 회원가입 유저인지 확인 + User user = userRepository.findByAuthProviderAndAuthProviderIdAndIsDeletedFalse(Provider.NAVER, providerId) + .orElseGet(() -> { + User newUser = createAndSaveUser(request); + newUser.getAuth().updateProviderId(providerId); + log.info("회원가입 완료"); + return newUser; + }); + + return createAccessAndSaveRefresh(user); + } + + @Transactional + public LoginResponse googleLogin(String code, SignupRequest request) { + log.info("구글 로그인 실행"); + GoogleAccessResponse googleAccessResponse = getGoogleAccessToken(code); + log.info("액세스 토큰 발급 완료"); + + GoogleUserInfoResponse googleuserInfo = getGoogleUserInfo(googleAccessResponse.getAccess_token()); + log.info("providerId 획득"); + + String providerId = String.valueOf(googleuserInfo.getProviderId()); + + // 회원가입 유저인지 확인 + User user = userRepository.findByAuthProviderAndAuthProviderIdAndIsDeletedFalse(Provider.GOOGLE, providerId) + .orElseGet(() -> { + User newUser = createAndSaveUser(request); + newUser.getAuth().updateProviderId(providerId); + log.info("회원가입 완료"); + return newUser; + }); + + return createAccessAndSaveRefresh(user); + + } + + private User createAndSaveUser(SignupRequest request) { + if (userRepository.existsByEmail(request.getAuth().getEmail())) { + throw new CustomException(CustomErrorCode.EMAIL_DUPLICATED); + } + + if (userRepository.existsByProfileNickName(request.getProfile().getNickName())) { + throw new CustomException(CustomErrorCode.NICKNAME_DUPLICATED); + } + + String encryptedPassword = passwordEncoder.encode(request.getAuth().getPassword()); + + User user = User.create( + request.getAuth().getEmail(), + encryptedPassword, + request.getProfile().getName(), + request.getProfile().getPhoneNumber(), + request.getProfile().getNickName(), + request.getProfile().getBirthDate(), + request.getProfile().getGender(), + request.getProfile().getAddress().getProvince(), + request.getProfile().getAddress().getDistrict(), + request.getProfile().getAddress().getDetailAddress(), + request.getProfile().getAddress().getPostalCode(), + request.getAuth().getProvider() + ); + + return userRepository.save(user); + } + + private LoginResponse createAccessAndSaveRefresh(User user) { + + String newAccessToken = jwtUtil.createAccessToken(user.getId(), user.getRole().name()); + String newRefreshToken = jwtUtil.createRefreshToken(user.getId(), user.getRole().name()); + + String redisKey = "refreshToken" + user.getId(); + userRedisPort.saveRedisKey(redisKey, newRefreshToken, Duration.ofDays(7)); + + return LoginResponse.of(newAccessToken, newRefreshToken, user); + } + + private void addBlackLists(String authHeader) { + + String accessToken = jwtUtil.subStringToken(authHeader); + + Long remainingTime = jwtUtil.getExpiration(accessToken); + String blackListTokenKey = "blackListToken" + accessToken; + + userRedisPort.saveRedisKey(blackListTokenKey, "logout", Duration.ofMillis(remainingTime)); + } + + private KakaoAccessResponse getKakaoAccessToken(String code) { + + try { + KakaoOAuthRequest request = KakaoOAuthRequest.of( + "authorization_code", + kakaoOAuthProperties.getClientId(), + kakaoOAuthProperties.getRedirectUri(), + code); + + return OAuthPort.getKakaoAccess(request); + } catch (Exception e) { + throw new CustomException(CustomErrorCode.OAUTH_ACCESS_TOKEN_FAILED); + } + } + + private KakaoUserInfoResponse getKakaoUserInfo(String accessToken) { + try { + return OAuthPort.getKakaoUserInfo("Bearer " + accessToken); + } catch (Exception e) { + log.error("카카오 사용자 정보 조회 실패, accessToken: {}", accessToken, e); + throw new CustomException(CustomErrorCode.PROVIDER_ID_NOT_FOUNT); + } + } + + private NaverAccessResponse getNaverAcessToken(String code) { + try { + NaverOAuthRequest request = NaverOAuthRequest.of( + "authorization_code", + naverOAuthProperties.getClientId(), + naverOAuthProperties.getClientSecret(), + code, + "test_state_123"); + + return OAuthPort.getNaverAccess(request); + } catch (Exception e) { + throw new CustomException(CustomErrorCode.OAUTH_ACCESS_TOKEN_FAILED); + } + } + + private NaverUserInfoResponse getNaverUserInfo(String accessToken) { + try { + return OAuthPort.getNaverUserInfo("Bearer " + accessToken); + } catch (Exception e) { + log.error("네이버 사용자 정보 조회 실패, accessToken: {}", accessToken, e); + throw new CustomException(CustomErrorCode.PROVIDER_ID_NOT_FOUNT); + } + } + + private GoogleAccessResponse getGoogleAccessToken(String code) { + try { + GoogleOAuthRequest request = GoogleOAuthRequest.of( + "authorization_code", + googleOAuthProperties.getClientId(), + googleOAuthProperties.getClientSecret(), + googleOAuthProperties.getRedirectUri(), + code); + + return OAuthPort.getGoogleAccess(request); + } catch (Exception e) { + throw new CustomException(CustomErrorCode.OAUTH_ACCESS_TOKEN_FAILED); + } + } + + private GoogleUserInfoResponse getGoogleUserInfo(String accessToken) { + try { + + return OAuthPort.getGoogleUserInfo("Bearer " + accessToken); + } catch (Exception e) { + log.error("구글 사용자 정보 조회 실패, accessToken: {}", accessToken, e); + throw new CustomException(CustomErrorCode.PROVIDER_ID_NOT_FOUNT); + } + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/UserService.java b/user-module/src/main/java/com/example/surveyapi/user/application/UserService.java new file mode 100644 index 000000000..2b578b71d --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/UserService.java @@ -0,0 +1,106 @@ +package com.example.surveyapi.user.application; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.surveyapi.user.application.dto.request.UpdateUserRequest; +import com.example.surveyapi.user.application.dto.response.UpdateUserResponse; +import com.example.surveyapi.user.application.dto.response.UserByEmailResponse; +import com.example.surveyapi.user.application.dto.response.UserGradeResponse; +import com.example.surveyapi.user.application.dto.response.UserInfoResponse; +import com.example.surveyapi.user.application.dto.response.UserSnapShotResponse; +import com.example.surveyapi.user.domain.command.UserGradePoint; +import com.example.surveyapi.global.auth.jwt.PasswordEncoder; +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.UserRepository; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + @Transactional(readOnly = true) + public Page getAll(Pageable pageable) { + + Page users = userRepository.gets(pageable); + + return users.map(UserInfoResponse::from); + } + + @Transactional(readOnly = true) + public UserInfoResponse getUser(Long userId) { + + User user = userRepository.findByIdAndIsDeletedFalse(userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + return UserInfoResponse.from(user); + } + + @Transactional(readOnly = true) + public UserGradeResponse getGradeAndPoint(Long userId) { + + UserGradePoint userGradePoint = userRepository.findByGradeAndPoint(userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.GRADE_POINT_NOT_FOUND)); + + return UserGradeResponse.from(userGradePoint); + } + + @Transactional + public UpdateUserResponse update(UpdateUserRequest request, Long userId) { + + User user = userRepository.findByIdAndIsDeletedFalse(userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + String encryptedPassword = Optional.ofNullable(request.getPassword()) + .map(passwordEncoder::encode) + .orElseGet(() -> user.getAuth().getPassword()); + + UpdateUserRequest.UpdateData data = UpdateUserRequest.UpdateData.of(request, encryptedPassword); + + user.update( + data.getPassword(), data.getName(), + data.getPhoneNumber(), data.getNickName(), + data.getProvince(), data.getDistrict(), + data.getDetailAddress(), data.getPostalCode() + ); + + userRepository.save(user); + + return UpdateUserResponse.from(user); + } + + @Transactional(readOnly = true) + public UserSnapShotResponse snapshot(Long userId) { + User user = userRepository.findByIdAndIsDeletedFalse(userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + return UserSnapShotResponse.from(user); + } + + public void updatePoint(Long userId) { + User user = userRepository.findByIdAndIsDeletedFalse(userId) + .orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND)); + + user.increasePoint(); + userRepository.save(user); + } + + public UserByEmailResponse byEmail(String email){ + Long userId = userRepository.findIdByAuthEmail(email) + .orElseThrow(() -> new CustomException(CustomErrorCode.USERID_NOT_FOUND)); + + return UserByEmailResponse.from(userId); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/port/OAuthPort.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/port/OAuthPort.java new file mode 100644 index 000000000..f12c521a6 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/port/OAuthPort.java @@ -0,0 +1,25 @@ +package com.example.surveyapi.user.application.client.port; + +import com.example.surveyapi.user.application.client.request.GoogleOAuthRequest; +import com.example.surveyapi.user.application.client.request.NaverOAuthRequest; +import com.example.surveyapi.user.application.client.response.GoogleAccessResponse; +import com.example.surveyapi.user.application.client.response.GoogleUserInfoResponse; +import com.example.surveyapi.user.application.client.response.KakaoAccessResponse; +import com.example.surveyapi.user.application.client.request.KakaoOAuthRequest; +import com.example.surveyapi.user.application.client.response.KakaoUserInfoResponse; +import com.example.surveyapi.user.application.client.response.NaverAccessResponse; +import com.example.surveyapi.user.application.client.response.NaverUserInfoResponse; + +public interface OAuthPort { + KakaoAccessResponse getKakaoAccess(KakaoOAuthRequest request); + + KakaoUserInfoResponse getKakaoUserInfo(String accessToken); + + NaverAccessResponse getNaverAccess(NaverOAuthRequest request); + + NaverUserInfoResponse getNaverUserInfo(String accessToken); + + GoogleAccessResponse getGoogleAccess(GoogleOAuthRequest request); + + GoogleUserInfoResponse getGoogleUserInfo(String accessToken); +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/port/UserRedisPort.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/port/UserRedisPort.java new file mode 100644 index 000000000..339c051c1 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/port/UserRedisPort.java @@ -0,0 +1,11 @@ +package com.example.surveyapi.user.application.client.port; + +import java.time.Duration; + +public interface UserRedisPort { + Boolean delete (Long userId); + + String getRedisKey(String key); + + void saveRedisKey(String key, String value, Duration expire); +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/request/GoogleOAuthRequest.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/request/GoogleOAuthRequest.java new file mode 100644 index 000000000..ac458d1dc --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/request/GoogleOAuthRequest.java @@ -0,0 +1,29 @@ +package com.example.surveyapi.user.application.client.request; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GoogleOAuthRequest { + private String grant_type; + private String client_id; + private String client_secret; + private String redirect_uri; + private String code; + + public static GoogleOAuthRequest of( + String grant_type, String client_id, + String client_secret, String redirect_uri, String code + ) { + GoogleOAuthRequest dto = new GoogleOAuthRequest(); + dto.grant_type = grant_type; + dto.client_id = client_id; + dto.client_secret = client_secret; + dto.redirect_uri = redirect_uri; + dto.code = code; + + return dto; + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/request/KakaoOAuthRequest.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/request/KakaoOAuthRequest.java new file mode 100644 index 000000000..8e935f6c8 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/request/KakaoOAuthRequest.java @@ -0,0 +1,27 @@ +package com.example.surveyapi.user.application.client.request; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class KakaoOAuthRequest { + private String grant_type; + private String client_id; + private String redirect_uri; + private String code; + + public static KakaoOAuthRequest of( + String grant_type, String client_id, + String redirect_uri, String code + ){ + KakaoOAuthRequest dto = new KakaoOAuthRequest(); + dto.grant_type = grant_type; + dto.client_id = client_id; + dto.redirect_uri = redirect_uri; + dto.code = code; + + return dto; + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/request/NaverOAuthRequest.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/request/NaverOAuthRequest.java new file mode 100644 index 000000000..fdbc2cd0b --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/request/NaverOAuthRequest.java @@ -0,0 +1,31 @@ +package com.example.surveyapi.user.application.client.request; + + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class NaverOAuthRequest { + private String grant_type; + private String client_id; + private String client_secret; + private String code; + private String state; + + public static NaverOAuthRequest of( + String grant_type, String client_id, + String client_secret, String code, String state + ){ + NaverOAuthRequest dto = new NaverOAuthRequest(); + dto.grant_type = grant_type; + dto.client_id = client_id; + dto.client_secret = client_secret; + dto.code = code; + dto.state = state; + + return dto; + } + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/response/GoogleAccessResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/GoogleAccessResponse.java new file mode 100644 index 000000000..054e46908 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/GoogleAccessResponse.java @@ -0,0 +1,26 @@ +package com.example.surveyapi.user.application.client.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GoogleAccessResponse { + + @JsonProperty("access_token") + private String access_token; + + @JsonProperty("expires_in") + private Integer expires_in; + + @JsonProperty("refresh_token") + private String refresh_token; + + @JsonProperty("scope") + private String scope; + + @JsonProperty("token_type") + private String token_type; +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/response/GoogleUserInfoResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/GoogleUserInfoResponse.java new file mode 100644 index 000000000..704130673 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/GoogleUserInfoResponse.java @@ -0,0 +1,14 @@ +package com.example.surveyapi.user.application.client.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class GoogleUserInfoResponse { + + @JsonProperty("sub") + private String providerId; +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/response/KakaoAccessResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/KakaoAccessResponse.java new file mode 100644 index 000000000..3d3b9bf00 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/KakaoAccessResponse.java @@ -0,0 +1,25 @@ +package com.example.surveyapi.user.application.client.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class KakaoAccessResponse { + @JsonProperty("token_type") + private String token_type; + + @JsonProperty("access_token") + private String access_token; + + @JsonProperty("expires_in") + private Integer expires_in; + + @JsonProperty("refresh_token") + private String refresh_token; + + @JsonProperty("refresh_token_expires_in") + private Integer refresh_token_expires_in; +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/response/KakaoUserInfoResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/KakaoUserInfoResponse.java new file mode 100644 index 000000000..0694378e5 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/KakaoUserInfoResponse.java @@ -0,0 +1,14 @@ +package com.example.surveyapi.user.application.client.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class KakaoUserInfoResponse { + + @JsonProperty("id") + private Long providerId; +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/response/NaverAccessResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/NaverAccessResponse.java new file mode 100644 index 000000000..dfed0d2dc --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/NaverAccessResponse.java @@ -0,0 +1,28 @@ +package com.example.surveyapi.user.application.client.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class NaverAccessResponse { + @JsonProperty("access_token") + private String access_token; + + @JsonProperty("refresh_token") + private String refresh_token; + + @JsonProperty("token_type") + private String token_type; + + @JsonProperty("expires_in") + private Integer expires_in; + + @JsonProperty("error") + private String error; + + @JsonProperty("error_description") + private String error_description; +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/client/response/NaverUserInfoResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/NaverUserInfoResponse.java new file mode 100644 index 000000000..7c049d1ab --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/client/response/NaverUserInfoResponse.java @@ -0,0 +1,23 @@ +package com.example.surveyapi.user.application.client.response; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class NaverUserInfoResponse { + + @JsonProperty("response") + private NaverUserInfo response; + + @Getter + @NoArgsConstructor + public static class NaverUserInfo{ + @JsonProperty("id") + private String providerId; + } + + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/LoginRequest.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/LoginRequest.java new file mode 100644 index 000000000..9acf0a5c1 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/LoginRequest.java @@ -0,0 +1,10 @@ +package com.example.surveyapi.user.application.dto.request; + +import lombok.Getter; + +@Getter +public class LoginRequest { + + private String email; + private String password; +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/SignupRequest.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/SignupRequest.java new file mode 100644 index 000000000..3a14da61a --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/SignupRequest.java @@ -0,0 +1,93 @@ +package com.example.surveyapi.user.application.dto.request; + +import java.time.LocalDateTime; + +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.user.enums.Gender; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class SignupRequest { + + @Valid + @NotNull(message = "인증 정보는 필수입니다.") + private AuthRequest auth; + + @Valid + @NotNull(message = "프로필 정보는 필수입니다.") + private ProfileRequest profile; + + @Getter + public static class AuthRequest { + @Email(message = "이메일 형식이 잘못됐습니다") + @NotBlank(message = "이메일은 필수입니다") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다") + @Size(min = 6, max = 20, message = "비밀번호는 6자 이상 20자 이하이어야 합니다") + private String password; + + @NotNull(message = "로그인 형식은 필수입니다.") + private Provider provider; + } + + @Getter + public static class ProfileRequest { + + @NotBlank(message = "이름은 필수입니다.") + @Size(max = 20, message = "이름은 최대 20자까지 가능합니다") + private String name; + + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern( + regexp = "^01[016789]-\\d{3,4}-\\d{4}$", + message = "전화번호 형식은 010-1234-5678과 같아야 합니다." + ) + private String phoneNumber; + + + @NotBlank(message = "닉네임은 필수입니다.") + @Size(min = 2, max = 20, message = "닉네임은 2자 이상 20자 이하로 입력해주세요.") + @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") + private String nickName; + + @NotNull(message = "생년월일은 필수입니다.") + private LocalDateTime birthDate; + + @NotNull(message = "성별은 필수입니다.") + private Gender gender; + + @Valid + @NotNull(message = "주소는 필수입니다.") + private AddressRequest address; + } + + @Getter + public static class AddressRequest { + + @NotBlank(message = "시/도는 필수입니다.") + @Size(max = 50, message = "시/도는 최대 50자까지 가능합니다") + private String province; + + @NotBlank(message = "구/군은 필수입니다.") + @Size(max = 50, message = "구/군은 최대 50자까지 가능합니다") + private String district; + + @NotBlank(message = "상세주소는 필수입니다.") + @Size(max = 100, message = "상세주소는 최대 100자까지 가능합니다") + private String detailAddress; + + @NotBlank(message = "우편번호는 필수입니다.") + @Pattern(regexp = "\\d{5}", message = "우편번호는 5자리 숫자여야 합니다") + private String postalCode; + + } + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/UpdateUserRequest.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/UpdateUserRequest.java new file mode 100644 index 000000000..011eecf02 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/UpdateUserRequest.java @@ -0,0 +1,88 @@ +package com.example.surveyapi.user.application.dto.request; + +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +public class UpdateUserRequest { + + @Size(min = 6, max = 20, message = "비밀번호는 6자 이상 20자 이하이어야 합니다") + private String password; + + @Size(max = 20, message = "이름은 최대 20자까지 가능합니다") + private String name; + + @Pattern(regexp = "^\\d{10,11}$", message = "전화번호는 숫자 10~11자리여야 합니다.") + private String phoneNumber; + + @Size(min = 2, max = 20, message = "닉네임은 2자 이상 20자 이하로 입력해주세요.") + @Pattern(regexp = "^[가-힣a-zA-Z0-9]+$", message = "닉네임은 한글, 영문, 숫자만 사용할 수 있습니다.") + private String nickName; + + @Size(max = 50, message = "시/도는 최대 50자까지 가능합니다") + private String province; + + @Size(max = 50, message = "구/군은 최대 50자까지 가능합니다") + private String district; + + @Size(max = 100, message = "상세주소는 최대 100자까지 가능합니다") + private String detailAddress; + + @Pattern(regexp = "\\d{5}", message = "우편번호는 5자리 숫자여야 합니다") + private String postalCode; + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class UpdateData { + + private String password; + private String name; + private String phoneNumber; + private String nickName; + private String province; + private String district; + private String detailAddress; + private String postalCode; + + public static UpdateData of(UpdateUserRequest request, String newPassword) { + + UpdateData dto = new UpdateData(); + + if(newPassword != null){ + dto.password = newPassword; + } + + if(request.name != null){ + dto.name = request.name; + } + + if(request.phoneNumber != null){ + dto.phoneNumber = request.phoneNumber; + } + + if(request.nickName != null){ + dto.nickName = request.nickName; + } + + if(request.province != null){ + dto.province = request.province; + } + + if(request.district != null){ + dto.district = request.district; + } + + if(request.detailAddress != null){ + dto.detailAddress = request.detailAddress; + } + + if(request.postalCode != null){ + dto.postalCode = request.postalCode; + } + return dto; + } + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/UserWithdrawRequest.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/UserWithdrawRequest.java new file mode 100644 index 000000000..c325f083e --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/request/UserWithdrawRequest.java @@ -0,0 +1,10 @@ +package com.example.surveyapi.user.application.dto.request; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; + +@Getter +public class UserWithdrawRequest { + @NotBlank(message = "비밀번호는 필수입니다") + private String password; +} \ No newline at end of file diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/LoginResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/LoginResponse.java new file mode 100644 index 000000000..36e7d71e7 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/LoginResponse.java @@ -0,0 +1,51 @@ +package com.example.surveyapi.user.application.dto.response; + +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.enums.Role; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LoginResponse { + + private String accessToken; + private String refreshToken; + private MemberResponse member; + + public static LoginResponse of( + String accessToken, String refreshToken, User user + ) { + LoginResponse dto = new LoginResponse(); + dto.accessToken = accessToken; + dto.refreshToken = refreshToken; + dto.member = MemberResponse.from(user); + + return dto; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class MemberResponse { + + private Long memberId; + private String email; + private String name; + private Role role; + + public static MemberResponse from( + User user + ) { + MemberResponse dto = new MemberResponse(); + + dto.memberId = user.getId(); + dto.email = user.getAuth().getEmail(); + dto.name = user.getProfile().getName(); + dto.role = user.getRole(); + + return dto; + } + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/SignupResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/SignupResponse.java new file mode 100644 index 000000000..3ae2c06a2 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/SignupResponse.java @@ -0,0 +1,28 @@ +package com.example.surveyapi.user.application.dto.response; + +import com.example.surveyapi.user.domain.user.User; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SignupResponse { + + private Long memberId; + private String email; + private String name; + + public static SignupResponse from( + User user + ) { + SignupResponse dto = new SignupResponse(); + + dto.memberId = user.getId(); + dto.email = user.getAuth().getEmail(); + dto.name = user.getProfile().getName(); + + return dto; + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UpdateUserResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UpdateUserResponse.java new file mode 100644 index 000000000..b59228bf7 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UpdateUserResponse.java @@ -0,0 +1,69 @@ +package com.example.surveyapi.user.application.dto.response; + +import java.time.LocalDateTime; + +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.enums.Gender; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UpdateUserResponse { + + private Long memberId; + private LocalDateTime updatedAt; + private ProfileResponse profile; + + public static UpdateUserResponse from( + User user + ) { + UpdateUserResponse dto = new UpdateUserResponse(); + ProfileResponse profileDto = new ProfileResponse(); + AddressResponse addressDto = new AddressResponse(); + + dto.memberId = user.getId(); + dto.updatedAt = user.getUpdatedAt(); + dto.profile = profileDto; + + + profileDto.name = user.getProfile().getName(); + profileDto.phoneNumber = user.getProfile().getPhoneNumber(); + profileDto.nickName = user.getProfile().getNickName(); + profileDto.birthDate = user.getDemographics().getBirthDate(); + profileDto.gender = user.getDemographics().getGender(); + profileDto.address = addressDto; + + addressDto.province = user.getDemographics().getAddress().getProvince(); + addressDto.district = user.getDemographics().getAddress().getDistrict(); + addressDto.detailAddress = user.getDemographics().getAddress().getDetailAddress(); + addressDto.postalCode = user.getDemographics().getAddress().getPostalCode(); + + return dto; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class ProfileResponse { + + private String name; + private String phoneNumber; + private String nickName; + private LocalDateTime birthDate; + private Gender gender; + private AddressResponse address; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class AddressResponse { + + private String province; + private String district; + private String detailAddress; + private String postalCode; + } + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserByEmailResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserByEmailResponse.java new file mode 100644 index 000000000..a64424daa --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserByEmailResponse.java @@ -0,0 +1,18 @@ +package com.example.surveyapi.user.application.dto.response; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UserByEmailResponse { + private Long userId; + + public static UserByEmailResponse from(Long userId) { + UserByEmailResponse dto = new UserByEmailResponse(); + dto.userId = userId; + + return dto; + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserGradeResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserGradeResponse.java new file mode 100644 index 000000000..e46ba59d0 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserGradeResponse.java @@ -0,0 +1,27 @@ +package com.example.surveyapi.user.application.dto.response; + +import com.example.surveyapi.user.domain.command.UserGradePoint; +import com.example.surveyapi.user.domain.user.enums.Grade; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UserGradeResponse { + + private Grade grade; + private int point; + + public static UserGradeResponse from( + UserGradePoint userGradePoint + ) { + UserGradeResponse dto = new UserGradeResponse(); + + dto.grade = userGradePoint.getGrade(); + dto.point = userGradePoint.getPoint(); + + return dto; + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserInfoResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserInfoResponse.java new file mode 100644 index 000000000..6c2ea6d23 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserInfoResponse.java @@ -0,0 +1,80 @@ +package com.example.surveyapi.user.application.dto.response; + +import java.time.LocalDateTime; + +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.enums.Gender; +import com.example.surveyapi.user.domain.user.enums.Grade; +import com.example.surveyapi.user.domain.user.enums.Role; + +import lombok.AccessLevel; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UserInfoResponse { + + private Long memberId; + private String email; + private Role role; + private Grade grade; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + private ProfileResponse profile; + + public static UserInfoResponse from( + User user + ) { + UserInfoResponse dto = new UserInfoResponse(); + ProfileResponse profileDto = new ProfileResponse(); + AddressResponse addressDto = new AddressResponse(); + + dto.memberId = user.getId(); + dto.email = user.getAuth().getEmail(); + dto.role = user.getRole(); + dto.grade = user.getGrade(); + dto.createdAt = user.getCreatedAt(); + dto.updatedAt = user.getUpdatedAt(); + dto.profile = profileDto; + + + profileDto.name = user.getProfile().getName(); + profileDto.phoneNumber = user.getProfile().getPhoneNumber(); + profileDto.nickName = user.getProfile().getNickName(); + profileDto.birthDate = user.getDemographics().getBirthDate(); + profileDto.gender = user.getDemographics().getGender(); + profileDto.address = addressDto; + + addressDto.province = user.getDemographics().getAddress().getProvince(); + addressDto.district = user.getDemographics().getAddress().getDistrict(); + addressDto.detailAddress = user.getDemographics().getAddress().getDetailAddress(); + addressDto.postalCode = user.getDemographics().getAddress().getPostalCode(); + + return dto; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class ProfileResponse { + + private String name; + private String phoneNumber; + private String nickName; + private LocalDateTime birthDate; + private Gender gender; + private AddressResponse address; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class AddressResponse { + + private String province; + private String district; + private String detailAddress; + private String postalCode; + } + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserSnapShotResponse.java b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserSnapShotResponse.java new file mode 100644 index 000000000..9f4fbcf93 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/dto/response/UserSnapShotResponse.java @@ -0,0 +1,44 @@ +package com.example.surveyapi.user.application.dto.response; + +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.enums.Gender; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UserSnapShotResponse { + private String birth; + private Gender gender; + private Region region; + + public static UserSnapShotResponse from(User user) { + UserSnapShotResponse dto = new UserSnapShotResponse(); + + dto.birth = String.valueOf(user.getDemographics().getBirthDate()); + dto.gender = user.getDemographics().getGender(); + dto.region = Region.from(user); + + return dto; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PRIVATE) + public static class Region { + private String province; + private String district; + + public static Region from(User user) { + Region dto = new Region(); + + dto.district = user.getDemographics().getAddress().getDistrict(); + dto.province = user.getDemographics().getAddress().getProvince(); + + return dto; + } + } + + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventListener.java b/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventListener.java new file mode 100644 index 000000000..4f29e3fbe --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventListener.java @@ -0,0 +1,30 @@ +package com.example.surveyapi.user.application.event; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import com.example.surveyapi.user.domain.user.event.UserEvent; +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.user.UserWithdrawEvent; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class UserEventListener { + + private final UserEventPublisherPort rabbitPublisher; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(UserEvent domainEvent){ + log.info("이벤트 발행 전 "); + UserWithdrawEvent globalEvent = objectMapper.convertValue(domainEvent, UserWithdrawEvent.class); + rabbitPublisher.publish(globalEvent, EventCode.USER_WITHDRAW); + log.info("이벤트 발행 후 "); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventListenerPort.java b/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventListenerPort.java new file mode 100644 index 000000000..a4bfb7723 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventListenerPort.java @@ -0,0 +1,8 @@ +package com.example.surveyapi.user.application.event; + +public interface UserEventListenerPort { + + void surveyCompletion(Long userId); + + void participation(Long userId); +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventPublisherPort.java b/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventPublisherPort.java new file mode 100644 index 000000000..113a48d58 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/event/UserEventPublisherPort.java @@ -0,0 +1,9 @@ +package com.example.surveyapi.user.application.event; + +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.user.WithdrawEvent; + +public interface UserEventPublisherPort { + + void publish(WithdrawEvent event, EventCode key); +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/application/event/UserHandlerEvent.java b/user-module/src/main/java/com/example/surveyapi/user/application/event/UserHandlerEvent.java new file mode 100644 index 000000000..ccafd586a --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/application/event/UserHandlerEvent.java @@ -0,0 +1,37 @@ +package com.example.surveyapi.user.application.event; + +import org.springframework.stereotype.Service; + +import com.example.surveyapi.user.application.UserService; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserHandlerEvent implements UserEventListenerPort { + private final UserService userService; + + @Override + public void surveyCompletion(Long userId) { + try { + log.info("설문 종료"); + userService.updatePoint(userId); + log.info("포인트 상승"); + } catch (Exception e) { + log.error("포인트 상승 실패 , 등급 상승 실패 : {}", e.getMessage()); + } + } + + @Override + public void participation(Long userId) { + try { + log.info("참여 완료"); + userService.updatePoint(userId); + log.info("참여자 포인트 상승"); + } catch (Exception e) { + log.error("참여자 포인트 상승 실패 , 등급 상승 실패 : {}", e.getMessage()); + } + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/auth/Auth.java b/user-module/src/main/java/com/example/surveyapi/user/domain/auth/Auth.java new file mode 100644 index 000000000..99190e2a2 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/auth/Auth.java @@ -0,0 +1,85 @@ +package com.example.surveyapi.user.domain.auth; + +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.global.model.BaseEntity; +import com.example.surveyapi.user.domain.util.MaskingUtils; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +public class Auth extends BaseEntity { + + @Id + private Long id; + + @OneToOne + @MapsId + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, unique = true) + private String email; + + @Column + private String password; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Provider provider; + + @Column(name = "provider_id", unique = true) + private String providerId; + + private Auth( + User user, String email, String password, + Provider provider, String providerId + ) { + this.user = user; + this.email = email; + this.password = password; + this.provider = provider; + this.providerId = providerId; + + } + + public static Auth create( + User user, String email, String password, + Provider provider, String providerId + ) { + Auth auth = new Auth(); + auth.user = user; + auth.email = email; + auth.password = password; + auth.provider = provider; + auth.providerId = providerId; + + return auth; + } + + public void updateProviderId(String providerId) { + this.providerId = providerId; + } + + public void updateAuth(String newPassword) { + if (password != null) { + this.password = newPassword; + } + } + + public void masking() { + this.email = MaskingUtils.maskEmail(email, user.getId()); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/auth/enums/Provider.java b/user-module/src/main/java/com/example/surveyapi/user/domain/auth/enums/Provider.java new file mode 100644 index 000000000..e25d5393c --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/auth/enums/Provider.java @@ -0,0 +1,6 @@ +package com.example.surveyapi.user.domain.auth.enums; + +public enum Provider { + LOCAL, NAVER, KAKAO, GOOGLE + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/command/UserGradePoint.java b/user-module/src/main/java/com/example/surveyapi/user/domain/command/UserGradePoint.java new file mode 100644 index 000000000..58667754b --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/command/UserGradePoint.java @@ -0,0 +1,13 @@ +package com.example.surveyapi.user.domain.command; + +import com.example.surveyapi.user.domain.user.enums.Grade; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class UserGradePoint { + private Grade grade; + private int point; +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/demographics/Demographics.java b/user-module/src/main/java/com/example/surveyapi/user/domain/demographics/Demographics.java new file mode 100644 index 000000000..fb946efd0 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/demographics/Demographics.java @@ -0,0 +1,69 @@ +package com.example.surveyapi.user.domain.demographics; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.enums.Gender; +import com.example.surveyapi.user.domain.demographics.vo.Address; +import com.example.surveyapi.global.model.BaseEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.OneToOne; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@Entity +@Getter +public class Demographics extends BaseEntity { + + @Id + private Long id; + + @OneToOne + @MapsId + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "birth_date", nullable = false) + private LocalDateTime birthDate; + + @Column(name = "gender", nullable = false) + private Gender gender; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "address", nullable = false, columnDefinition = "jsonb") + private Address address; + + private Demographics( + User user, LocalDateTime birthDate, + Gender gender, Address address + ) { + this.user = user; + this.birthDate = birthDate; + this.gender = gender; + this.address = address; + } + + public static Demographics create( + User user, LocalDateTime birthDate, + Gender gender, Address address + ) { + Demographics demographics = new Demographics( + user, birthDate, + gender, address); + user.setDemographics(demographics); + return demographics; + } + + public void masking(){ + this.address.masking(); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/demographics/vo/Address.java b/user-module/src/main/java/com/example/surveyapi/user/domain/demographics/vo/Address.java new file mode 100644 index 000000000..46200ef0f --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/demographics/vo/Address.java @@ -0,0 +1,60 @@ +package com.example.surveyapi.user.domain.demographics.vo; + +import com.example.surveyapi.user.domain.util.MaskingUtils; + +import jakarta.persistence.Embeddable; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class Address { + + private String province; + private String district; + private String detailAddress; + private String postalCode; + + public static Address create( + String province, String district, + String detailAddress, String postalCode + ) { + Address address = new Address(); + address.province = province; + address.district = district; + address.detailAddress = detailAddress; + address.postalCode = postalCode; + + return address; + } + + public void updateAddress( + String province, String district, + String detailAddress, String postalCode + ) { + if (province != null) { + this.province = province; + } + + if (district != null) { + this.district = district; + } + + if (detailAddress != null) { + this.detailAddress = detailAddress; + } + + if (postalCode != null) { + this.postalCode = postalCode; + } + } + + public void masking() { + this.district = MaskingUtils.maskDistrict(district); + this.detailAddress = MaskingUtils.maskDetailAddress(detailAddress); + this.postalCode = MaskingUtils.maskPostalCode(postalCode); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/user/User.java b/user-module/src/main/java/com/example/surveyapi/user/domain/user/User.java new file mode 100644 index 000000000..d1bcebfb8 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/user/User.java @@ -0,0 +1,170 @@ +package com.example.surveyapi.user.domain.user; + +import java.time.LocalDateTime; + +import com.example.surveyapi.user.domain.auth.Auth; +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.demographics.Demographics; +import com.example.surveyapi.user.domain.user.enums.Gender; +import com.example.surveyapi.user.domain.user.enums.Grade; +import com.example.surveyapi.user.domain.user.enums.Role; +import com.example.surveyapi.user.domain.user.event.UserEvent; +import com.example.surveyapi.user.domain.demographics.vo.Address; +import com.example.surveyapi.user.domain.user.vo.Profile; +import com.example.surveyapi.global.model.AbstractRoot; + +import jakarta.persistence.AttributeOverride; +import jakarta.persistence.AttributeOverrides; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@NoArgsConstructor +@Entity +@Getter +@Table(name = "users") +public class User extends AbstractRoot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Embedded + @Column(name = "profile", nullable = false) + @AttributeOverrides({ + @AttributeOverride(name = "nickName", column = @Column(name = "profile_nick_name", unique = true)) + }) + private Profile profile; + + @Column(name = "role", nullable = false) + @Enumerated(EnumType.STRING) + private Role role; + + @Column(name = "grade", nullable = false) + @Enumerated(EnumType.STRING) + private Grade grade; + + @Column(name = "point", nullable = false) + private int point; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Auth auth; + + @OneToOne(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + private Demographics demographics; + + private User(Profile profile) { + this.profile = profile; + this.role = Role.USER; + this.grade = Grade.BRONZE; + this.point = 0; + } + + public void setDemographics(Demographics demographics) { + this.demographics = demographics; + } + + public static User create( + String email, String password, + String name, String phoneNumber, String nickname, + LocalDateTime birthDate, Gender gender, + String province, String district, + String detailAddress, String postalCode, + Provider provider + + ) { + Address address = Address.create( + province, district, + detailAddress, postalCode); + + Profile profile = Profile.create( + name, phoneNumber, nickname); + + User user = new User(profile); + + Auth auth = Auth.create( + user, email, password, + provider, null); + + user.auth = auth; + + Demographics demographics = Demographics.create( + user, birthDate, + gender, address); + + user.demographics = demographics; + + return user; + } + + public void update( + String password, String name, + String phoneNumber, String nickName, + String province, String district, + String detailAddress, String postalCode) { + + this.auth.updateAuth(password); + + this.profile.updateProfile(name, phoneNumber, nickName); + + this.demographics.getAddress(). + updateAddress(province, district, detailAddress, postalCode); + + this.setUpdatedAt(LocalDateTime.now()); + } + + public void registerUserWithdrawEvent() { + log.info("이벤트 등록 전"); + registerEvent(new UserEvent(this.getId())); + log.info("이벤트 등록 후"); + } + + public void delete() { + this.isDeleted = true; + this.auth.delete(); + this.demographics.delete(); + + this.auth.masking(); + this.profile.masking(); + this.demographics.masking(); + + registerUserWithdrawEvent(); + } + + public void increasePoint() { + if (this.grade == Grade.MASTER && this.point == 99) { + return; + } + + this.point += 5; + updatePointGrade(); + } + + private void updatePointGrade() { + if (this.grade == Grade.MASTER && this.point >= 100) { + this.point = 99; + return; + } + + if (this.point >= 100) { + this.point -= 100; + if (this.grade.next() != null) { + this.grade = this.grade.next(); + } + } + } + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/user/UserRepository.java b/user-module/src/main/java/com/example/surveyapi/user/domain/user/UserRepository.java new file mode 100644 index 000000000..8dd3a994a --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/user/UserRepository.java @@ -0,0 +1,32 @@ +package com.example.surveyapi.user.domain.user; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.command.UserGradePoint; + +public interface UserRepository { + + boolean existsByEmail(String email); + + boolean existsByProfileNickName(String nickname); + + User save(User user); + + Optional findByEmailAndIsDeletedFalse(String email); + + Page gets(Pageable pageable); + + Optional findByIdAndIsDeletedFalse(Long userId); + + Optional findById(Long userId); + + Optional findByGradeAndPoint(Long userId); + + Optional findByAuthProviderAndAuthProviderIdAndIsDeletedFalse(Provider provider, String providerId); + + Optional findIdByAuthEmail(String email); +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Gender.java b/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Gender.java new file mode 100644 index 000000000..71aebe7a9 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Gender.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.user.domain.user.enums; + +public enum Gender { + MALE, FEMALE +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Grade.java b/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Grade.java new file mode 100644 index 000000000..21ffe1e23 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Grade.java @@ -0,0 +1,22 @@ +package com.example.surveyapi.user.domain.user.enums; + + + +public enum Grade { + MASTER(null), + DIAMOND(MASTER), + PLATINUM(DIAMOND), + GOLD(PLATINUM), + SILVER(GOLD), + BRONZE(SILVER); + + private final Grade next; + + Grade (Grade next) { + this.next = next; + } + + public Grade next() { + return next; + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Role.java b/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Role.java new file mode 100644 index 000000000..d3ed3b393 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/user/enums/Role.java @@ -0,0 +1,5 @@ +package com.example.surveyapi.user.domain.user.enums; + +public enum Role { + ADMIN, USER +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/user/event/UserAbstractRoot.java b/user-module/src/main/java/com/example/surveyapi/user/domain/user/event/UserAbstractRoot.java new file mode 100644 index 000000000..3136c42f9 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/user/event/UserAbstractRoot.java @@ -0,0 +1,36 @@ +package com.example.surveyapi.user.domain.user.event; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import org.springframework.data.annotation.Transient; +import org.springframework.data.domain.AfterDomainEventPublication; +import org.springframework.data.domain.DomainEvents; + +import com.example.surveyapi.global.model.BaseEntity; + +import io.jsonwebtoken.lang.Assert; + +public class UserAbstractRoot> extends BaseEntity { + + private transient final @Transient List domainEvents = new ArrayList<>(); + + protected void registerEvent(T event) { + + Assert.notNull(event, "event must not be null"); + + this.domainEvents.add(event); + } + + @AfterDomainEventPublication + protected void clearDomainEvents(){ + this.domainEvents.clear(); + } + + @DomainEvents + protected Collection domainEvents(){ + return Collections.unmodifiableList(domainEvents); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/user/event/UserEvent.java b/user-module/src/main/java/com/example/surveyapi/user/domain/user/event/UserEvent.java new file mode 100644 index 000000000..d3aa4b500 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/user/event/UserEvent.java @@ -0,0 +1,12 @@ +package com.example.surveyapi.user.domain.user.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UserEvent { + private Long userId; +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/user/vo/Profile.java b/user-module/src/main/java/com/example/surveyapi/user/domain/user/vo/Profile.java new file mode 100644 index 000000000..235cd2dd1 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/user/vo/Profile.java @@ -0,0 +1,48 @@ +package com.example.surveyapi.user.domain.user.vo; + +import com.example.surveyapi.user.domain.util.MaskingUtils; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Profile { + + private String name; + private String phoneNumber; + private String nickName; + + public static Profile create( + String name, String phoneNumber, String nickName) { + Profile profile = new Profile(); + profile.name = name; + profile.phoneNumber = phoneNumber; + profile.nickName = nickName; + + return profile; + } + + public void updateProfile(String name, String phoneNumber, String nickName) { + if (name != null) { + this.name = name; + } + + if (phoneNumber != null) { + this.phoneNumber = phoneNumber; + } + + if (nickName != null) { + this.nickName = nickName; + } + } + + public void masking() { + this.name = MaskingUtils.maskName(name); + this.phoneNumber = MaskingUtils.maskPhoneNumber(phoneNumber); + } + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/domain/util/MaskingUtils.java b/user-module/src/main/java/com/example/surveyapi/user/domain/util/MaskingUtils.java new file mode 100644 index 000000000..dc53dd77f --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/domain/util/MaskingUtils.java @@ -0,0 +1,50 @@ +package com.example.surveyapi.user.domain.util; + +public class MaskingUtils { + + public static String maskName(String name) { + + if (name.length() < 2) { + return ""; + } + + int mid = name.length() / 2; + + return name.substring(0, mid) + "*" + name.substring(mid + 1); + } + + public static String maskEmail(String email, Long userId) { + int atIndex = email.indexOf("@"); + if (atIndex == -1) { + return email; + } + + String prefix = email.substring(0, atIndex); + String domain = email.substring(atIndex); + String maskPrefix = + prefix.length() < 3 ? + "*".repeat(prefix.length()) : + prefix.substring(0, 3) + "*".repeat(prefix.length() - 3); + return maskPrefix + "+" + userId + domain; + } + + public static String maskPhoneNumber(String phoneNumber) { + return phoneNumber.replaceAll("(\\d{3})-\\d{4}-(\\d{2})(\\d{2})", "$1-****-**$3"); + } + + public static String maskDistrict(String district) { + return "*".repeat(district.length()); + } + + public static String maskDetailAddress(String address) { + return "*".repeat(address.length()); + } + + public static String maskPostalCode(String postalCode) { + if (postalCode.length() < 2) { + return "*".repeat(postalCode.length()); + } + + return postalCode.substring(0, 2) + "*".repeat(postalCode.length() - 2); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/infra/adapter/OAuthAdapter.java b/user-module/src/main/java/com/example/surveyapi/user/infra/adapter/OAuthAdapter.java new file mode 100644 index 000000000..096ccc0a3 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/infra/adapter/OAuthAdapter.java @@ -0,0 +1,80 @@ +package com.example.surveyapi.user.infra.adapter; + +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.example.surveyapi.user.application.client.port.OAuthPort; +import com.example.surveyapi.user.application.client.request.GoogleOAuthRequest; +import com.example.surveyapi.user.application.client.request.KakaoOAuthRequest; +import com.example.surveyapi.user.application.client.request.NaverOAuthRequest; + +import com.example.surveyapi.user.application.client.response.GoogleAccessResponse; +import com.example.surveyapi.user.application.client.response.GoogleUserInfoResponse; +import com.example.surveyapi.user.application.client.response.KakaoAccessResponse; +import com.example.surveyapi.user.application.client.response.KakaoUserInfoResponse; +import com.example.surveyapi.user.application.client.response.NaverAccessResponse; +import com.example.surveyapi.user.application.client.response.NaverUserInfoResponse; +import com.example.surveyapi.global.client.OAuthApiClient; +import com.fasterxml.jackson.databind.ObjectMapper; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class OAuthAdapter implements OAuthPort { + + private final OAuthApiClient OAuthApiClient; + private final ObjectMapper objectMapper; + + @Override + public KakaoAccessResponse getKakaoAccess(KakaoOAuthRequest request) { + Map data = OAuthApiClient.getKakaoAccessToken( + request.getGrant_type(), request.getClient_id(), + request.getRedirect_uri(), request.getCode()); + + return objectMapper.convertValue(data, KakaoAccessResponse.class); + + } + + @Override + public KakaoUserInfoResponse getKakaoUserInfo(String accessToken) { + Map data = OAuthApiClient.getKakaoUserInfo(accessToken); + + return objectMapper.convertValue(data, KakaoUserInfoResponse.class); + } + + @Override + public NaverAccessResponse getNaverAccess(NaverOAuthRequest request) { + Map data = OAuthApiClient.getNaverAccessToken( + request.getGrant_type(), request.getClient_id(), + request.getClient_secret(), request.getCode(), + request.getState()); + + return objectMapper.convertValue(data, NaverAccessResponse.class); + } + + @Override + public NaverUserInfoResponse getNaverUserInfo(String accessToken) { + Map data = OAuthApiClient.getNaverUserInfo(accessToken); + + return objectMapper.convertValue(data, NaverUserInfoResponse.class); + } + + @Override + public GoogleAccessResponse getGoogleAccess(GoogleOAuthRequest request) { + Map data = OAuthApiClient.getGoogleAccessToken( + request.getGrant_type(), request.getClient_id(), + request.getClient_secret(), request.getRedirect_uri(), + request.getCode()); + + return objectMapper.convertValue(data, GoogleAccessResponse.class); + } + + @Override + public GoogleUserInfoResponse getGoogleUserInfo(String accessToken) { + Map data = OAuthApiClient.getGoogleUserInfo(accessToken); + + return objectMapper.convertValue(data, GoogleUserInfoResponse.class); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/infra/adapter/UserRedisAdapter.java b/user-module/src/main/java/com/example/surveyapi/user/infra/adapter/UserRedisAdapter.java new file mode 100644 index 000000000..c8efb4a0f --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/infra/adapter/UserRedisAdapter.java @@ -0,0 +1,33 @@ +package com.example.surveyapi.user.infra.adapter; + +import java.time.Duration; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.user.application.client.port.UserRedisPort; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class UserRedisAdapter implements UserRedisPort { + + private final RedisTemplate redisTemplate; + + @Override + public Boolean delete(Long userId) { + String redisKey = "refreshToken" + userId; + return redisTemplate.delete(redisKey); + } + + @Override + public String getRedisKey(String key) { + return redisTemplate.opsForValue().get(key); + } + + @Override + public void saveRedisKey(String key, String value, Duration expire) { + redisTemplate.opsForValue().set(key, value, expire); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/infra/event/UserConsumer.java b/user-module/src/main/java/com/example/surveyapi/user/infra/event/UserConsumer.java new file mode 100644 index 000000000..9bb43c000 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/infra/event/UserConsumer.java @@ -0,0 +1,37 @@ +package com.example.surveyapi.user.infra.event; + +import org.springframework.amqp.rabbit.annotation.RabbitHandler; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Component; + +import com.example.surveyapi.user.application.event.UserEventListenerPort; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.participation.ParticipationCreatedGlobalEvent; +import com.example.surveyapi.global.event.survey.SurveyActivateEvent; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +@RabbitListener( + queues = RabbitConst.QUEUE_NAME_USER +) +public class UserConsumer { + + private final UserEventListenerPort userEventListenerPort; + + @RabbitHandler + public void handleSurveyCompletion(SurveyActivateEvent event) { + if (!"CLOSED".equals(event.getSurveyStatus())) { + return; + } + userEventListenerPort.surveyCompletion(event.getCreatorId()); + } + + @RabbitHandler + public void handleParticipation(ParticipationCreatedGlobalEvent event) { + userEventListenerPort.participation(event.getUserId()); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/infra/event/UserEventPublisher.java b/user-module/src/main/java/com/example/surveyapi/user/infra/event/UserEventPublisher.java new file mode 100644 index 000000000..b59370c37 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/infra/event/UserEventPublisher.java @@ -0,0 +1,25 @@ +package com.example.surveyapi.user.infra.event; + +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.stereotype.Service; + +import com.example.surveyapi.user.application.event.UserEventPublisherPort; +import com.example.surveyapi.global.event.RabbitConst; +import com.example.surveyapi.global.event.EventCode; +import com.example.surveyapi.global.event.user.WithdrawEvent; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class UserEventPublisher implements UserEventPublisherPort { + + private final RabbitTemplate rabbitTemplate; + + @Override + public void publish(WithdrawEvent event, EventCode key) { + if(key.equals(EventCode.USER_WITHDRAW)){ + rabbitTemplate.convertAndSend(RabbitConst.EXCHANGE_NAME, RabbitConst.ROUTING_KEY_USER_WITHDRAW, event); + } + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/infra/user/UserRepositoryImpl.java b/user-module/src/main/java/com/example/surveyapi/user/infra/user/UserRepositoryImpl.java new file mode 100644 index 000000000..ce4eb0708 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/infra/user/UserRepositoryImpl.java @@ -0,0 +1,75 @@ +package com.example.surveyapi.user.infra.user; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.command.UserGradePoint; +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.UserRepository; +import com.example.surveyapi.user.infra.user.dsl.QueryDslRepository; +import com.example.surveyapi.user.infra.user.jpa.UserJpaRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepository { + + private final UserJpaRepository userJpaRepository; + private final QueryDslRepository queryDslRepository; + + @Override + public boolean existsByEmail(String email) { + return userJpaRepository.existsByAuthEmail(email); + } + + @Override + public boolean existsByProfileNickName(String nickname) { + return userJpaRepository.existsByProfileNickName(nickname); + } + + @Override + public User save(User user) { + return userJpaRepository.save(user); + } + + @Override + public Optional findByEmailAndIsDeletedFalse(String email) { + return userJpaRepository.findByAuthEmailAndIsDeletedFalse(email); + } + + @Override + public Page gets(Pageable pageable) { + return queryDslRepository.gets(pageable); + } + + @Override + public Optional findByIdAndIsDeletedFalse(Long memberId) { + return userJpaRepository.findByIdAndIsDeletedFalse(memberId); + } + + @Override + public Optional findById(Long userId) { + return userJpaRepository.findById(userId); + } + + @Override + public Optional findByGradeAndPoint(Long userId) { + return userJpaRepository.findByGradeAndPoint(userId); + } + + @Override + public Optional findByAuthProviderAndAuthProviderIdAndIsDeletedFalse(Provider provider, String providerId) { + return userJpaRepository.findByAuthProviderAndAuthProviderIdAndIsDeletedFalse(provider, providerId); + } + + @Override + public Optional findIdByAuthEmail(String email) { + return userJpaRepository.findIdByAuthEmail(email); + } + +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/infra/user/dsl/QueryDslRepository.java b/user-module/src/main/java/com/example/surveyapi/user/infra/user/dsl/QueryDslRepository.java new file mode 100644 index 000000000..d487a206a --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/infra/user/dsl/QueryDslRepository.java @@ -0,0 +1,61 @@ +package com.example.surveyapi.user.infra.user.dsl; + +import static com.example.surveyapi.user.domain.user.QUser.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.global.exception.CustomErrorCode; +import com.example.surveyapi.global.exception.CustomException; + +import com.querydsl.core.types.dsl.BooleanPath; +import com.querydsl.core.types.dsl.DateTimePath; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class QueryDslRepository { + + private final JPAQueryFactory queryFactory; + private final BooleanPath isDeleted = Expressions.booleanPath(user,"isDeleted"); + private final DateTimePath createdAt = + Expressions.dateTimePath(LocalDateTime.class, user,"createdAt"); + + public Page gets(Pageable pageable) { + + Long total = queryFactory. + select(user.count()) + .from(user) + .where(isDeleted.eq(false)) + .fetchOne(); + + long totalCount = total != null ? total : 0L; + + if (totalCount == 0L) { + throw new CustomException(CustomErrorCode.USER_LIST_EMPTY); + } + + List users = queryFactory + .selectFrom(user) + .leftJoin(user.auth) + .fetchJoin() + .leftJoin(user.demographics) + .fetchJoin() + .where(isDeleted.eq(false)) + .orderBy(createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + return new PageImpl<>(users, pageable, totalCount); + } +} diff --git a/user-module/src/main/java/com/example/surveyapi/user/infra/user/jpa/UserJpaRepository.java b/user-module/src/main/java/com/example/surveyapi/user/infra/user/jpa/UserJpaRepository.java new file mode 100644 index 000000000..28fc5fee8 --- /dev/null +++ b/user-module/src/main/java/com/example/surveyapi/user/infra/user/jpa/UserJpaRepository.java @@ -0,0 +1,35 @@ +package com.example.surveyapi.user.infra.user.jpa; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.command.UserGradePoint; +import com.example.surveyapi.user.domain.user.User; + +public interface UserJpaRepository extends JpaRepository { + + boolean existsByAuthEmail(String email); + + boolean existsByProfileNickName(String nickname); + + @Query("SELECT u FROM User u join fetch u.auth a join fetch u.demographics d WHERE a.email = :authEmail AND a.isDeleted = false") + Optional findByAuthEmailAndIsDeletedFalse(@Param("authEmail") String authEmail); + + @Query("SELECT u FROM User u join fetch u.auth a join fetch u.demographics d WHERE u.id = :userId AND u.isDeleted = false") + Optional findByIdAndIsDeletedFalse(Long userId); + + Optional findById(Long usreId); + + @Query("SELECT u.grade, u.point FROM User u WHERE u.id = :userId") + Optional findByGradeAndPoint(@Param("userId") Long userId); + + Optional findByAuthProviderAndAuthProviderIdAndIsDeletedFalse(Provider provider, String authProviderId); + + @Query("SELECT u.id FROM User u join u.auth a WHERE a.email = :email") + Optional findIdByAuthEmail(@Param("email") String email); + +} diff --git a/src/test/java/com/example/surveyapi/domain/user/api/UserControllerTest.java b/user-module/src/test/java/com/example/surveyapi/user/api/UserControllerTest.java similarity index 90% rename from src/test/java/com/example/surveyapi/domain/user/api/UserControllerTest.java rename to user-module/src/test/java/com/example/surveyapi/user/api/UserControllerTest.java index c6c98e253..730539274 100644 --- a/src/test/java/com/example/surveyapi/domain/user/api/UserControllerTest.java +++ b/user-module/src/test/java/com/example/surveyapi/user/api/UserControllerTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.user.api; +package com.example.surveyapi.user.api; import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; @@ -26,16 +26,28 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import com.example.surveyapi.domain.user.application.AuthService; -import com.example.surveyapi.domain.user.application.UserService; -import com.example.surveyapi.domain.user.application.dto.request.SignupRequest; -import com.example.surveyapi.domain.user.application.dto.request.UpdateUserRequest; -import com.example.surveyapi.domain.user.application.dto.response.UserGradeResponse; -import com.example.surveyapi.domain.user.application.dto.response.UserInfoResponse; -import com.example.surveyapi.domain.user.domain.auth.enums.Provider; -import com.example.surveyapi.domain.user.domain.command.UserGradePoint; -import com.example.surveyapi.domain.user.domain.user.User; -import com.example.surveyapi.domain.user.domain.user.enums.Gender; +import static org.mockito.ArgumentMatchers.any; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import static org.mockito.BDDMockito.given; + +import java.time.LocalDateTime; +import java.util.List; + +import com.example.surveyapi.user.application.AuthService; +import com.example.surveyapi.user.application.UserService; +import com.example.surveyapi.user.application.dto.request.SignupRequest; +import com.example.surveyapi.user.application.dto.request.UpdateUserRequest; +import com.example.surveyapi.user.application.dto.response.UserGradeResponse; +import com.example.surveyapi.user.application.dto.response.UserInfoResponse; +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.command.UserGradePoint; +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.enums.Gender; + import com.example.surveyapi.global.exception.CustomErrorCode; import com.example.surveyapi.global.exception.CustomException; import com.example.surveyapi.global.exception.GlobalExceptionHandler; diff --git a/src/test/java/com/example/surveyapi/domain/user/application/UserServiceTest.java b/user-module/src/test/java/com/example/surveyapi/user/application/UserServiceTest.java similarity index 93% rename from src/test/java/com/example/surveyapi/domain/user/application/UserServiceTest.java rename to user-module/src/test/java/com/example/surveyapi/user/application/UserServiceTest.java index dedbea794..9c87b6a79 100644 --- a/src/test/java/com/example/surveyapi/domain/user/application/UserServiceTest.java +++ b/user-module/src/test/java/com/example/surveyapi/user/application/UserServiceTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.user.application; +package com.example.surveyapi.user.application; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; @@ -29,18 +29,18 @@ import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; -import com.example.surveyapi.domain.user.application.dto.request.SignupRequest; -import com.example.surveyapi.domain.user.application.dto.request.UpdateUserRequest; -import com.example.surveyapi.domain.user.application.dto.request.UserWithdrawRequest; -import com.example.surveyapi.domain.user.application.dto.response.SignupResponse; -import com.example.surveyapi.domain.user.application.dto.response.UpdateUserResponse; -import com.example.surveyapi.domain.user.application.dto.response.UserGradeResponse; -import com.example.surveyapi.domain.user.application.dto.response.UserInfoResponse; -import com.example.surveyapi.domain.user.domain.auth.enums.Provider; -import com.example.surveyapi.domain.user.domain.user.User; -import com.example.surveyapi.domain.user.domain.user.UserRepository; -import com.example.surveyapi.domain.user.domain.user.enums.Gender; -import com.example.surveyapi.domain.user.domain.user.enums.Grade; +import com.example.surveyapi.user.application.dto.request.SignupRequest; +import com.example.surveyapi.user.application.dto.request.UpdateUserRequest; +import com.example.surveyapi.user.application.dto.request.UserWithdrawRequest; +import com.example.surveyapi.user.application.dto.response.SignupResponse; +import com.example.surveyapi.user.application.dto.response.UpdateUserResponse; +import com.example.surveyapi.user.application.dto.response.UserGradeResponse; +import com.example.surveyapi.user.application.dto.response.UserInfoResponse; +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.UserRepository; +import com.example.surveyapi.user.domain.user.enums.Gender; +import com.example.surveyapi.user.domain.user.enums.Grade; import com.example.surveyapi.global.auth.jwt.JwtUtil; import com.example.surveyapi.global.auth.jwt.PasswordEncoder; import com.example.surveyapi.global.dto.ExternalApiResponse; diff --git a/src/test/java/com/example/surveyapi/domain/user/domain/UserTest.java b/user-module/src/test/java/com/example/surveyapi/user/domain/UserTest.java similarity index 92% rename from src/test/java/com/example/surveyapi/domain/user/domain/UserTest.java rename to user-module/src/test/java/com/example/surveyapi/user/domain/UserTest.java index e8b82e402..424140223 100644 --- a/src/test/java/com/example/surveyapi/domain/user/domain/UserTest.java +++ b/user-module/src/test/java/com/example/surveyapi/user/domain/UserTest.java @@ -1,4 +1,4 @@ -package com.example.surveyapi.domain.user.domain; +package com.example.surveyapi.user.domain; import static org.assertj.core.api.Assertions.*; @@ -7,9 +7,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.example.surveyapi.domain.user.domain.auth.enums.Provider; -import com.example.surveyapi.domain.user.domain.user.User; -import com.example.surveyapi.domain.user.domain.user.enums.Gender; +import com.example.surveyapi.user.domain.auth.enums.Provider; +import com.example.surveyapi.user.domain.user.User; +import com.example.surveyapi.user.domain.user.enums.Gender; public class UserTest { diff --git a/web-app/build.gradle b/web-app/build.gradle new file mode 100644 index 000000000..602698708 --- /dev/null +++ b/web-app/build.gradle @@ -0,0 +1,16 @@ +dependencies { + implementation project(':shared-kernel') + implementation project(':user-module') + implementation project(':project-module') + implementation project(':survey-module') + implementation project(':participation-module') + implementation project(':statistic-module') + implementation project(':share-module') + + runtimeOnly 'org.postgresql:postgresql' + + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:postgresql' + testImplementation 'org.testcontainers:mongodb' + testImplementation 'org.springframework.test:spring-test' +} diff --git a/src/main/java/com/example/surveyapi/SurveyApiApplication.java b/web-app/src/main/java/com/example/surveyapi/SurveyApiApplication.java similarity index 100% rename from src/main/java/com/example/surveyapi/SurveyApiApplication.java rename to web-app/src/main/java/com/example/surveyapi/SurveyApiApplication.java diff --git a/web-app/src/main/resources/application-prod.yml b/web-app/src/main/resources/application-prod.yml new file mode 100644 index 000000000..6c14b2352 --- /dev/null +++ b/web-app/src/main/resources/application-prod.yml @@ -0,0 +1,121 @@ + +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_SCHEME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + hikari: + minimum-idle: 10 + maximum-pool-size: 30 + connection-timeout: 10000 + idle-timeout: 600000 + max-lifetime: 1800000 + jpa: + hibernate: + ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO:validate} + properties: + hibernate: + format_sql: false + show_sql: false + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + batch_size: 100 + batch_versioned_data: true + order_inserts: true + order_updates: true + batch_fetch_style: DYNAMIC + default_batch_fetch_size: 100 + cache: + cache-names: + - projectMemberCache + - projectStateCache + caffeine: + spec: > + initialCapacity=200, + maximumSize=1000, + expireAfterWrite=10m, + expireAfterAccess=5m, + recordStats + rabbitmq: + host: ${RABBITMQ_HOST} + port: ${RABBITMQ_PORT} + username: ${RABBITMQ_USERNAME} + password: ${RABBITMQ_PASSWORD} + elasticsearch: + uris: ${ELASTIC_URIS} + data: + mongodb: + host: ${MONGODB_HOST} + port: ${MONGODB_PORT} + database: ${MONGODB_DATABASE} + username: ${MONGODB_USERNAME} + password: ${MONGODB_PASSWORD} + authentication-database: ${MONGODB_AUTHDB} + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_ADDRESS} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +firebase: + enabled: ${FIREBASE_ENABLED:true} + project-id: ${FIREBASE_PROJECT_ID:survey-f5a93} + private-key-id: ${FIREBASE_PRIVATE_KEY_ID} + private-key: ${FIREBASE_PRIVATE_KEY} + client-email: ${FIREBASE_CLIENT_EMAIL:firebase-adminsdk-fbsvc@survey-f5a93.iam.gserviceaccount.com} + client-id: ${FIREBASE_CLIENT_ID:100191250643521230154} + +server: + tomcat: + threads: + max: 50 + min-spare: 20 + +management: + endpoints: + web: + exposure: + include: "health,info,metrics,prometheus" + endpoint: + health: + show-details: when_authorized + metrics: + distribution: + percentiles-histogram: + http.server.requests: true + percentiles: + http.server.requests: 0.5,0.95,0.99 + health: + elasticsearch: + enabled: ${MANAGEMENT_HEALTH_ELASTICSEARCH_ENABLED:false} + mail: + enabled: ${MANAGEMENT_HEALTH_MAIL_ENABLED:false} + +jwt: + secret: + key: ${SECRET_KEY} + statistic: + token: ${STATISTIC_TOKEN:} + +oauth: + kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URL} + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_SECRET} + redirect-uri: ${NAVER_REDIRECT_URL} + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URL} diff --git a/web-app/src/main/resources/application.yml b/web-app/src/main/resources/application.yml new file mode 100644 index 000000000..4e11a37f6 --- /dev/null +++ b/web-app/src/main/resources/application.yml @@ -0,0 +1,127 @@ +spring: + profiles: + active: dev + datasource: + driver-class-name: org.postgresql.Driver + url: jdbc:postgresql://localhost:5432/survey_db + username: survey_user + password: survey_password + hikari: + minimum-idle: 5 + maximum-pool-size: 10 + connection-timeout: 5000 + idle-timeout: 600000 + max-lifetime: 1800000 + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + show_sql: false + dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + batch_size: 50 + batch_versioned_data: true + order_inserts: true + order_updates: true + batch_fetch_style: DYNAMIC + default_batch_fetch_size: 50 + cache: + cache-names: + - projectMemberCache + - projectStateCache + caffeine: + spec: > + initialCapacity=100, + maximumSize=500, + expireAfterWrite=5m, + expireAfterAccess=2m, + recordStats + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USERNAME:user} + password: ${RABBITMQ_PASSWORD:password} + elasticsearch: + uris: ${ELASTIC_URIS:http://localhost:9200} + data: + mongodb: + host: ${MONGODB_HOST:localhost} + port: ${MONGODB_PORT:27017} + database: ${MONGODB_DATABASE:survey_read_db} + username: ${MONGODB_USERNAME:survey_user} + password: ${MONGODB_PASSWORD:survey_password} + authentication-database: ${MONGODB_AUTHDB:admin} + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_ADDRESS} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true +firebase: + enabled: ${FIREBASE_ENABLED:true} + credentials: + path: ${FIREBASE_CREDENTIALS_PATH:classpath:firebase-survey-account.json} + project-id: ${FIREBASE_PROJECT_ID:survey-f5a93} + private-key-id: ${FIREBASE_PRIVATE_KEY_ID:} + private-key: ${FIREBASE_PRIVATE_KEY:} + client-email: ${FIREBASE_CLIENT_EMAIL:firebase-adminsdk-fbsvc@survey-f5a93.iam.gserviceaccount.com} + client-id: ${FIREBASE_CLIENT_ID:100191250643521230154} + +server: + tomcat: + threads: + max: 20 + min-spare: 10 + +# Actuator 설정 +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + show-details: always + metrics: + distribution: + percentiles-histogram: + http.server.requests: true + percentiles: + http.server.requests: 0.5,0.95,0.99 + health: + elasticsearch: + enabled: false + mail: + enabled: false + +jwt: + secret: + key: ${SECRET_KEY} + statistic: + token: ${STATISTIC_TOKEN} + +oauth: + kakao: + client-id: ${KAKAO_CLIENT_ID} + redirect-uri: ${KAKAO_REDIRECT_URL} + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_SECRET} + redirect-uri: ${NAVER_REDIRECT_URL} + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_SECRET} + redirect-uri: ${GOOGLE_REDIRECT_URL} + +api: + base-url: ${API_BASE_URL:http://localhost:8080} \ No newline at end of file diff --git a/web-app/src/main/resources/elasticsearch/statistic-mappings.json b/web-app/src/main/resources/elasticsearch/statistic-mappings.json new file mode 100644 index 000000000..d10aa90ae --- /dev/null +++ b/web-app/src/main/resources/elasticsearch/statistic-mappings.json @@ -0,0 +1,56 @@ +{ + "properties": { + "responseId": { + "type": "keyword" + }, + "surveyId": { + "type": "keyword" + }, + "questionId": { + "type": "long" + }, + "questionText": { + "type": "text", + "analyzer": "nori" + }, + "questionType": { + "type": "keyword" + }, + "choiceId": { + "type": "integer" + }, + "choiceText": { + "type": "text", + "analyzer": "nori", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "responseText": { + "type": "text", + "analyzer": "nori" + }, + "userId": { + "type": "keyword" + }, + "userGender": { + "type": "keyword" + }, + "userBirthDate": { + "type": "date", + "format": "yyyy-MM-dd" + }, + "userAge": { + "type": "integer" + }, + "userAgeGroup": { + "type": "keyword" + }, + "submittedAt": { + "type": "date", + "format": "strict_date_optional_time||epoch_millis" + } + } +} \ No newline at end of file diff --git a/web-app/src/main/resources/elasticsearch/statistic-settings.json b/web-app/src/main/resources/elasticsearch/statistic-settings.json new file mode 100644 index 000000000..2b9ac6cb6 --- /dev/null +++ b/web-app/src/main/resources/elasticsearch/statistic-settings.json @@ -0,0 +1,11 @@ +{ + "analysis": { + "analyzer": { + "nori_analyzer": { + "type": "custom", + "tokenizer": "nori_tokenizer", + "filter": ["lowercase"] + } + } + } +} \ No newline at end of file diff --git a/web-app/src/main/resources/project.sql b/web-app/src/main/resources/project.sql new file mode 100644 index 000000000..a3726de66 --- /dev/null +++ b/web-app/src/main/resources/project.sql @@ -0,0 +1,54 @@ +-- pg_trgm extension for trigram indexing +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +-- projects Table +CREATE TABLE IF NOT EXISTS projects +( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + description TEXT NOT NULL, + owner_id BIGINT NOT NULL, + period_start TIMESTAMPTZ NOT NULL, + period_end TIMESTAMPTZ NOT NULL, + state VARCHAR(50) NOT NULL DEFAULT 'PENDING', + max_members INTEGER NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_projects_name_trigram ON projects USING gin (lower(name) gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_projects_description_trigram ON projects USING gin (lower(description) gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_projects_state_deleted_start ON projects (state, is_deleted, period_start); +CREATE INDEX IF NOT EXISTS idx_projects_state_deleted_end ON projects (state, is_deleted, period_end); +CREATE INDEX IF NOT EXISTS idx_projects_created_at ON projects (created_at); + + +-- project_members Table +CREATE TABLE IF NOT EXISTS project_members +( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT fk_project_members_project FOREIGN KEY (project_id) REFERENCES projects (id) +); +CREATE UNIQUE INDEX IF NOT EXISTS uidx_project_members_project_user ON project_members (project_id, user_id) WHERE is_deleted = false; +CREATE INDEX IF NOT EXISTS idx_project_members_user_id ON project_members (user_id, is_deleted); + + +-- project_managers Table +CREATE TABLE IF NOT EXISTS project_managers +( + id BIGSERIAL PRIMARY KEY, + project_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role VARCHAR(50) NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT fk_project_managers_project FOREIGN KEY (project_id) REFERENCES projects (id) +); +CREATE UNIQUE INDEX IF NOT EXISTS uidx_project_managers_project_user ON project_managers (project_id, user_id) WHERE is_deleted = false; +CREATE INDEX IF NOT EXISTS idx_project_managers_user_id ON project_managers (user_id, is_deleted); diff --git a/web-app/src/main/resources/prometheus.yml b/web-app/src/main/resources/prometheus.yml new file mode 100644 index 000000000..711edc8a6 --- /dev/null +++ b/web-app/src/main/resources/prometheus.yml @@ -0,0 +1,28 @@ +scrape_configs: + - job_name: "prometheus" + static_configs: + - targets: [ "localhost:9090" ] + + # ======================================================= + # == 데이터를 수집할 스프링 부트 애플리케이션 정보를 추가합니다 == + - job_name: "survey-app" + scrape_interval: 15s + metrics_path: "/actuator/prometheus" + static_configs: + - targets: [ "host.docker.internal:8080" ] + # ======================================================= + +# 도커 컴포즈로 프로메테우스 서버 실행 +# docker-compose up -d prometheus + +# 프로메테우스 서버 중지 +# docker-compose stop prometheus + +# 프로메테우스 서버 재시작 +# docker-compose restart prometheus + +# 프로메테우스 서버 삭제 +# docker-compose down prometheus + +# 그라파나 실행 +# docker-compose up -d grafana \ No newline at end of file