diff --git a/.github/ISSUE_TEMPLATE/issue_template.md b/.github/ISSUE_TEMPLATE/issue_template.md new file mode 100644 index 00000000..e1972966 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue_template.md @@ -0,0 +1,20 @@ +--- +name: "\bIssue 생성 템플릿" +about: 해당 Issue 생성 템플릿을 통하여 Issue를 생성해주세요. +title: 'Feat: Issue 제목' +labels: '' +assignees: '' + +--- + +### 📝 Description + +- 구현할 내용 1 +- 구현할 내용 2 + +--- + +### 📝 Todo + +- [ ] 구현할 내용 1 +- [ ] 구현할 내용 2 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..893f7db7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,35 @@ +## ✅ PR 유형 +어떤 변경 사항이 있었나요? + +- [ ] 새로운 기능 추가 +- [ ] 버그 수정 +- [ ] 코드에 영향을 주지 않는 변경사항(오타 수정, 탭 사이즈 변경, 변수명 변경) +- [ ] 코드 리팩토링 +- [ ] 주석 추가 및 수정 +- [ ] 문서 수정 +- [ ] 빌드 부분 혹은 패키지 매니저 수정 +- [ ] 파일 혹은 폴더명 수정 +- [ ] 파일 혹은 폴더 삭제 + +--- + +## 📝 작업 내용 +이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지 첨부 가능) + +- 작업한 내용 1 +- 작업한 내용 2 + +--- + +## ✏️ 관련 이슈 +본인이 작업한 내용이 어떤 Issue Number와 관련이 있는지만 작성해주세요 + +ex) +- Fixes : #00 (수정중인 이슈) +- Resolves : #100 (무슨 이슈를 해결했는지) +- Ref : #00 #01 (참고할 이슈) +- Related to : #00 #01 (해당 커밋과 관련) + +--- + +## 🎸 기타 사항 or 추가 코멘트 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..cd2447bb --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,114 @@ +name: Sarang Backend CI/CD + +on: + push: + branches: [ main ] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up SSH key + uses: webfactory/ssh-agent@v0.5.3 + with: + ssh-private-key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + + - name: Gradle 캐시 적용 + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle + + - name: JDK 17 세팅 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: YML 파일 세팅 및 fcm.json 주입 + env: + APPLICATION_PROPERTIES: ${{ secrets.APPLICATION_PROPERTIES }} + TEST_APPLICATION_PROPERTIES: ${{ secrets.TEST_APPLICATION_PROPERTIES }} + ERROR_MESSAGES_PROPERTIES: ${{ secrets.ERROR_MESSAGES_PROPERTIES }} + FCM_JSON: ${{secrets.FCM_JSON}} + run: | + cd ./src + rm -rf main/resources/application.yml + mkdir -p test/resources + mkdir -p main/resources + mkdir -p main/resources/firebase + echo "$APPLICATION_PROPERTIES" > main/resources/application.yml + echo "$ERROR_MESSAGES_PROPERTIES" > main/resources/api-error-messages.properties + echo "$FCM_JSON" > main/resources/firebase/fcm.json + echo "$TEST_APPLICATION_PROPERTIES" > test/resources/application.yml + + - name: gradlew 권한 부여 + run: chmod +x gradlew + + - name: 테스트 수행 + run: ./gradlew test + + - name: 테스트 리포트 아티팩트 업로드 + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test + + - name: 스프링부트 빌드 + run: ./gradlew build + + - name: Docker Buildx 세팅 + uses: docker/setup-buildx-action@v3 + + - name: 도커 로그 + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: 도커 이미지 빌드 후 푸시 + if: success() + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ secrets.DOCKERHUB_USERNAME }}/sarang-backend:${{ github.sha }} + platforms: linux/amd64,linux/arm64 + + - name: Docker Compose 파일 EC2 서버로 전송 + run: scp -o StrictHostKeyChecking=no -P ${{ secrets.EC2_PORT }} docker-compose.yml ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:./ + + - name: Docker Compose Mornitoring 파일 EC2 서버로 전송 + run: scp -o StrictHostKeyChecking=no -P ${{ secrets.EC2_PORT }} ./observability/docker-compose.monitoring.yml ${{ secrets.EC2_USER }}@${{ secrets.EC2_HOST }}:~/observability/ + + - name: EC2 접속 후 이미지 다운로드 및 배포 + if: success() + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USER }} + key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + port: ${{ secrets.EC2_PORT }} + script: | + echo "DOCKER_CONTAINER_REGISTRY=${{ secrets.DOCKERHUB_USERNAME }}" > .env + echo "DOCKERHUB_PASSWORD=${{secrets.DOCKERHUB_PASSWORD}}" >> .env + echo "GITHUB_SHA=${{ github.sha }}" >> .env + echo "MINIO_ROOT_USER=${{secrets.MINIO_ROOT_USER}}" >> .env + echo "MINIO_ROOT_PASSWORD=${{secrets.MINIO_ROOT_PASSWORD}}" >> .env + echo "MINIO_SERVER_URL=${{secrets.MINIO_SERVER_URL}}" >> .env + echo "MINIO_BROWSER_REDIRECT_URL=${{secrets.MINIO_BROWSER_REDIRECT_URL}}" >> .env + echo "GF_SECURITY_ADMIN_PASSWORD=${{secrets.GF_SECURITY_ADMIN_PASSWORD}}" >> .env + echo "HOST_LOG_DIR=${{secrets.HOST_LOG_DIR}}" >> .env + chmod 600 .env + sudo chmod +x ./deploy.sh + ./deploy.sh diff --git a/.gitignore b/.gitignore index 5df6b146..8b29a3de 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ src/test/resources/application-test.yml .env api-error-messages.properties +.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..fa87115a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM openjdk:17.0.1-jdk-slim +WORKDIR /app +COPY ./build/libs/backend-0.0.1-SNAPSHOT.jar /app/backend.jar +EXPOSE 80 +ENTRYPOINT ["java"] +CMD ["-jar", "backend.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index bbc871c8..a61c09b4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,9 @@ plugins { - kotlin("jvm") version "1.9.25" - kotlin("plugin.spring") version "1.9.25" - kotlin("plugin.jpa") version "1.9.25" + kotlin("jvm") version "2.1.0" + kotlin("plugin.spring") version "2.1.0" + kotlin("plugin.jpa") version "2.1.0" + kotlin("plugin.allopen") version "2.1.0" + kotlin("plugin.serialization") version "2.1.0" id("org.springframework.boot") version "3.4.3" id("io.spring.dependency-management") version "1.1.7" } @@ -9,6 +11,9 @@ plugins { group = "gomushin" version = "0.0.1-SNAPSHOT" +val mockkVersion = "1.13.10" +val kotestVersion = "5.5.4" + java { toolchain { languageVersion = JavaLanguageVersion.of(17) @@ -32,8 +37,11 @@ dependencies { // mysql runtimeOnly("mysql:mysql-connector-java:8.0.33") + // h2 + runtimeOnly("com.h2database:h2") + // swagger - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.1") + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0") // mail implementation("org.springframework.boot:spring-boot-starter-mail") @@ -41,9 +49,53 @@ dependencies { // logging implementation("io.github.microutils:kotlin-logging:2.0.11") + // cache + implementation("org.springframework.boot:spring-boot-starter-cache:3.3.10") + implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") + + // redis + implementation("org.springframework.boot:spring-boot-starter-data-redis:3.3.10") + implementation("org.redisson:redisson:3.44.0") + + //security + implementation("org.springframework.boot:spring-boot-starter-security") + + //jwt + implementation("io.jsonwebtoken:jjwt-api:0.12.6") + implementation("io.jsonwebtoken:jjwt-impl:0.12.6") + implementation("io.jsonwebtoken:jjwt-jackson:0.12.6") + + // oauth2 + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + + // configuration processor + annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") + + // aws + implementation("software.amazon.awssdk:s3:2.30.38") + + //okhttp3 + implementation("com.squareup.okhttp3:okhttp:4.12.0") + + //google auth + implementation("com.google.auth:google-auth-library-oauth2-http:1.33.1") + + //coroutine + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3") + + //serializable + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.1") + + //logback-encoder + implementation ("net.logstash.logback:logstash-logback-encoder:7.4") + implementation("org.springframework.boot:spring-boot-starter-validation") testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.0.0") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testImplementation("io.mockk:mockk:${mockkVersion}") + testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") + testImplementation("io.kotest:kotest-assertions-core:$kotestVersion") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -55,8 +107,9 @@ kotlin { allOpen { annotation("jakarta.persistence.Entity") - annotation("jakarta.persistence.MappedSuperclass") annotation("jakarta.persistence.Embeddable") + annotation("jakarta.persistence.MappedSuperclass") + annotation("org.springframework.stereotype.Component") } tasks.withType { diff --git a/docker-compose-minio.yml b/docker-compose-minio.yml new file mode 100644 index 00000000..116dd84a --- /dev/null +++ b/docker-compose-minio.yml @@ -0,0 +1,16 @@ +version: '3.8' +services: + minio: + image: quay.io/minio/minio + command: server /data --console-address ":9001" + environment: + - MINIO_ROOT_USER=admin + - MINIO_ROOT_PASSWORD=12345678 + volumes: + - minio_data:/data + ports: + - "9000:9000" + - "9001:9001" + restart: always +volumes: + minio_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..6925cea3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +version: "3.8" + +services: + blue: + image: "${DOCKER_CONTAINER_REGISTRY}/sarang-backend:${GITHUB_SHA}" + container_name: sarang-backend-blue + environment: + TZ: Asia/Seoul + ports: + - '8080:8080' + volumes: + - ${HOST_LOG_DIR:-./logs}:/my/logs + depends_on: + - redis + - minio + networks: + - sarang-backend-network + + green: + image: "${DOCKER_CONTAINER_REGISTRY}/sarang-backend:${GITHUB_SHA}" + container_name: sarang-backend-green + environment: + TZ: Asia/Seoul + ports: + - '8081:8080' + volumes: + - ${HOST_LOG_DIR:-./logs}:/my/logs + depends_on: + - redis + - minio + networks: + - sarang-backend-network + + redis: + image: redis:6.0.9 + container_name: redis + ports: + - '6379:6379' + volumes: + - redis-data:/data + networks: + - sarang-backend-network + + minio: + image: quay.io/minio/minio + container_name: minio + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} + MINIO_SERVER_URL: ${MINIO_SERVER_URL} + MINIO_BROWSER_REDIRECT_URL: ${MINIO_BROWSER_REDIRECT_URL} + + volumes: + - minio_data:/data + ports: + - "9002:9000" + - "9003:9001" + networks: + - sarang-backend-network + restart: unless-stopped + + mc: + image: minio/mc + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set myminio http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD}; do echo 'MinIO 아직 준비 안됨...'; sleep 5; done; + mc mb -p myminio/gomushin; + mc anonymous set download myminio/gomushin; + exit 0; + " + networks: + - sarang-backend-network + +volumes: + redis-data: + minio_data: + +networks: + sarang-backend-network: + driver: bridge diff --git a/observability/docker-compose.monitoring.yml b/observability/docker-compose.monitoring.yml new file mode 100644 index 00000000..2b0a9e89 --- /dev/null +++ b/observability/docker-compose.monitoring.yml @@ -0,0 +1,43 @@ +version: '3.8' + +services: + loki: + image: grafana/loki:2.9.0 + ports: + - "3100:3100" + - "9096:9096" + volumes: + - ./loki:/etc/loki + command: -config.file=/etc/loki/local-config.yaml + restart: unless-stopped + networks: + - sarang-backend-network + + promtail: + image: grafana/promtail:2.9.0 + volumes: + - ./promtail/promtail-config.yaml:/etc/promtail/config.yaml:ro + - ${HOST_LOG_DIR:-./logs}:/var/my/logs:ro # 호스트 로그 → 컨테이너 안 경로 + restart: unless-stopped + networks: + - sarang-backend-network + depends_on: + - loki + + grafana: + image: grafana/grafana:10.2.0 + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GF_SECURITY_ADMIN_PASSWORD} + - GF_SECURITY_ADMIN_PWD__IS__SET=true + restart: unless-stopped + networks: + - sarang-backend-network + depends_on: + - loki + + +networks: + sarang-backend-network: + driver: bridge \ No newline at end of file diff --git a/observability/loki/local-config.yaml b/observability/loki/local-config.yaml new file mode 100644 index 00000000..2ab8d8ac --- /dev/null +++ b/observability/loki/local-config.yaml @@ -0,0 +1,65 @@ +auth_enabled: false + +server: + http_listen_port: 3100 + grpc_listen_port: 9096 + log_level: info + grpc_server_max_concurrent_streams: 100 + +common: + instance_addr: 127.0.0.1 + path_prefix: /tmp/loki + storage: + filesystem: + chunks_directory: /tmp/loki/chunks + rules_directory: /tmp/loki/rules + replication_factor: 1 + ring: + kvstore: + store: inmemory + +query_range: + results_cache: + cache: + embedded_cache: + enabled: true + max_size_mb: 100 + +limits_config: + metric_aggregation_enabled: true + enable_multi_variant_queries: true + +schema_config: + configs: + - from: 2020-10-24 + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: index_ + period: 24h + +pattern_ingester: + enabled: true + metric_aggregation: + loki_address: localhost:3100 + +ruler: + alertmanager_url: http://localhost:9093 + +frontend: + encoding: protobuf + + +# By default, Loki will send anonymous, but uniquely-identifiable usage and configuration +# analytics to Grafana Labs. These statistics are sent to https://stats.grafana.org/ +# +# Statistics help us better understand how Loki is used, and they show us performance +# levels for most users. This helps us prioritize features and documentation. +# For more information on what's sent, look at +# https://github.com/grafana/loki/blob/main/pkg/analytics/stats.go +# Refer to the buildReport method to see what goes into a report. +# +# If you would like to disable reporting, uncomment the following lines: +#analytics: +# reporting_enabled: false \ No newline at end of file diff --git a/observability/promtail/promtail-config.yaml b/observability/promtail/promtail-config.yaml new file mode 100644 index 00000000..41665294 --- /dev/null +++ b/observability/promtail/promtail-config.yaml @@ -0,0 +1,14 @@ +positions: + filename: /tmp/positions.yaml + +clients: + - url: http://localhost:3100/loki/api/v1/push + +scrape_configs: + - job_name: application-log + static_configs: + - targets: + - localhost + labels: + job: logs + __path__: /var/my/logs/*.log \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/BackendApplication.kt b/src/main/kotlin/gomushin/backend/BackendApplication.kt index ab29ecb3..63282034 100644 --- a/src/main/kotlin/gomushin/backend/BackendApplication.kt +++ b/src/main/kotlin/gomushin/backend/BackendApplication.kt @@ -3,9 +3,13 @@ package gomushin.backend import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.context.properties.ConfigurationPropertiesScan import org.springframework.boot.runApplication +import org.springframework.scheduling.annotation.EnableAsync +import org.springframework.scheduling.annotation.EnableScheduling @ConfigurationPropertiesScan @SpringBootApplication +@EnableAsync +@EnableScheduling class BackendApplication fun main(args: Array) { diff --git a/src/main/kotlin/gomushin/backend/alarm/dto/FCMMessage.kt b/src/main/kotlin/gomushin/backend/alarm/dto/FCMMessage.kt new file mode 100644 index 00000000..61bded41 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/dto/FCMMessage.kt @@ -0,0 +1,31 @@ +package gomushin.backend.alarm.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +data class FCMMessage( + val validateOnly: Boolean, + val message: Message +) { + data class Message( + val notification: Notification, + val token: String, + val webpush : Webpush? + ) + + data class Notification( + val title: String, + val body: String, + val image: String? + ) + @Serializable + data class Webpush( + @SerialName("fcm_options") + val fcmOptions : FcmOptions? + ) + + @Serializable + data class FcmOptions( + val link : String? + ) +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/alarm/dto/SaveAlarmMessage.kt b/src/main/kotlin/gomushin/backend/alarm/dto/SaveAlarmMessage.kt new file mode 100644 index 00000000..5816f8f2 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/dto/SaveAlarmMessage.kt @@ -0,0 +1,16 @@ +package gomushin.backend.alarm.dto + +import java.time.LocalDateTime + +data class SaveAlarmMessage( + val title: String, + val content: String, + val timestamp: String = LocalDateTime.now().toString() +) { + companion object { + fun of(title: String, content: String) = SaveAlarmMessage( + title, + content, + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/alarm/facade/DdayAlarmFacade.kt b/src/main/kotlin/gomushin/backend/alarm/facade/DdayAlarmFacade.kt new file mode 100644 index 00000000..0b797a98 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/facade/DdayAlarmFacade.kt @@ -0,0 +1,46 @@ +package gomushin.backend.alarm.facade + +import gomushin.backend.alarm.service.FCMService +import gomushin.backend.alarm.service.NotificationRedisService +import gomushin.backend.alarm.value.RedirectURL +import gomushin.backend.couple.domain.service.AnniversaryService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.time.LocalDate +import java.time.LocalDateTime + +@Service +class DdayAlarmFacade( + private val fcmService: FCMService, + private val anniversaryService: AnniversaryService, + private val notificationRedisService: NotificationRedisService, + private val redirectURL: RedirectURL +) { + private val log: Logger = LoggerFactory.getLogger(QuestionAlarmFacade::class.java) + private val alarmTitle = "오늘의 디데이가 도착했어요" + + @Scheduled(cron = "\${scheduling.cron.d-day}", zone = "\${scheduling.zone.seoul}") + fun sendDdayAlarms() { + val sendDdayAlarmContents = anniversaryService.getTodayAnniversaryMemberFcmTokens(LocalDate.now()) + log.info("전송 크기 : ${sendDdayAlarmContents.size}") + runBlocking { + sendDdayAlarmContents.map { content -> + async(Dispatchers.IO) { + try { + log.info("디데이 메시지 전송 : 수신자 {${content.memberId}}, 제목 {${alarmTitle}}, 내용{${content.title}}, 전송시각{${LocalDateTime.now()}}\n") + notificationRedisService.saveAlarm(alarmTitle, content.title, content.memberId) + fcmService.sendMessageTo(content.fcmToken, alarmTitle, content.title, redirectURL.dday) + } catch (e: Exception) { + log.error("디데이 메시지 전송오류 : 수신자 {${content.memberId}}, 전송시각{${LocalDateTime.now()}}\n") + } + } + }.awaitAll() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/alarm/facade/QuestionAlarmFacade.kt b/src/main/kotlin/gomushin/backend/alarm/facade/QuestionAlarmFacade.kt new file mode 100644 index 00000000..0bbb92e7 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/facade/QuestionAlarmFacade.kt @@ -0,0 +1,52 @@ +package gomushin.backend.alarm.facade + +import gomushin.backend.alarm.service.FCMService +import gomushin.backend.alarm.util.MessageParsingUtil +import gomushin.backend.alarm.service.NotificationRedisService +import gomushin.backend.alarm.value.RedirectURL +import gomushin.backend.member.domain.service.MemberService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.time.LocalDateTime + +@Service +class QuestionAlarmFacade ( + private val fcmService: FCMService, + private val memberService: MemberService, + private val notificationRedisService: NotificationRedisService, + private val redirectURL: RedirectURL +) { + private val log: Logger = LoggerFactory.getLogger(QuestionAlarmFacade::class.java) + private val questionMessages = listOf( + "오늘 하루는 어땠나요?+연인에게도 안부를 전해보세요", + "사랑은 매일 채우는 것+오늘도 연인과 한 조각 채워보세요", + "네가 있어서 참 좋아+연인에게 고마움을 전해보세요", + "매일 사랑이 자라요+가벼운 전화통화 어때요?" + ) + + @Scheduled(cron = "\${scheduling.cron.question}", zone = "\${scheduling.zone.seoul}") + fun sendQuestionAlarms() { + val coupleMembers = memberService.getAllCoupledMemberWithEnabledNotification() + runBlocking { + coupleMembers.map { member -> + async(Dispatchers.IO) { + try { + val notificationContent = questionMessages.random() + val (title, sendContent) = MessageParsingUtil.parse(notificationContent) + log.info("질문형 메시지 전송 : 수신자 {${member.id}}, 제목 {${title}}, 내용{${sendContent}}, 전송시각{${LocalDateTime.now()}}\n") + notificationRedisService.saveAlarm(title, sendContent, member.id) + fcmService.sendMessageTo(member.fcmToken, title, sendContent, redirectURL.main) + } catch (e: Exception) { + log.error("질문형 메시지 전송오류 : 수신자 {${member.name}}, 전송시각{${LocalDateTime.now()}}\n") + } + } + }.awaitAll() + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/alarm/service/FCMService.kt b/src/main/kotlin/gomushin/backend/alarm/service/FCMService.kt new file mode 100644 index 00000000..92769955 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/service/FCMService.kt @@ -0,0 +1,87 @@ +package gomushin.backend.alarm.service + + +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.google.auth.oauth2.GoogleCredentials +import gomushin.backend.alarm.dto.FCMMessage +import gomushin.backend.core.infrastructure.exception.BadRequestException +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Value +import org.springframework.core.io.ClassPathResource +import org.springframework.http.HttpHeaders +import org.springframework.stereotype.Service +import java.io.IOException + +@Service +class FCMService( + private val objectMapper: ObjectMapper, + @Value("\${fcm.api-url}") + private val API_URL : String, + @Value("\${fcm.firebase-config-path}") + private val firebaseConfigPath : String, + @Value("\${fcm.google.scope}") + private val googleScope : String +) { + private val log: Logger = LoggerFactory.getLogger(FCMService::class.java) + companion object { + private val client: OkHttpClient = OkHttpClient() + } + @Throws(IOException::class) + fun sendMessageTo(targetToken: String, title: String, body: String, link: String?) { + val message = makeMessage(targetToken, title, body, link) + + val requestBody = message + .toRequestBody("application/json; charset=utf-8".toMediaType()) + + val request = Request.Builder() + .url(API_URL) + .post(requestBody) + .addHeader(HttpHeaders.AUTHORIZATION, "Bearer ${getAccessToken()}") + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json; UTF-8") + .build() + + client.newCall(request).execute().use { response -> + log.info("fcm 결과 : " + response.body?.string()) + } + } + + @Throws(JsonProcessingException::class) + private fun makeMessage(targetToken: String, title: String, body: String, link:String?): String { + val fcmMessage = FCMMessage( + validateOnly = false, + message = FCMMessage.Message( + token = targetToken, + notification = FCMMessage.Notification( + title = title, + body = body, + image = null + ), + webpush = FCMMessage.Webpush( + FCMMessage.FcmOptions(link) + ) + ) + ) + return objectMapper.writeValueAsString(fcmMessage) + } + + @Throws(IOException::class) + private fun getAccessToken(): String { + try { + val googleCredentials = GoogleCredentials + .fromStream(ClassPathResource(firebaseConfigPath).inputStream) + .createScoped(listOf(googleScope)) + + googleCredentials.refreshIfExpired() + return googleCredentials.accessToken.tokenValue + } catch (e: IOException) { + log.error("FCM AccessToken 발급 중 오류 발생", e) + throw BadRequestException("sarangggun.alarm.fail-issue-accesstoken") + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/alarm/service/NotificationRedisService.kt b/src/main/kotlin/gomushin/backend/alarm/service/NotificationRedisService.kt new file mode 100644 index 00000000..da635238 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/service/NotificationRedisService.kt @@ -0,0 +1,48 @@ +package gomushin.backend.alarm.service + +import com.fasterxml.jackson.databind.ObjectMapper +import gomushin.backend.alarm.dto.SaveAlarmMessage +import gomushin.backend.core.configuration.redis.RedisKey +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Service +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@Service +class NotificationRedisService ( + private val redisTemplate: StringRedisTemplate, + private val objectMapper: ObjectMapper +) { + fun saveAlarm(title : String, content : String, receiverId : Long) { + val savedAlarmMessage = SaveAlarmMessage.of(title, content) + val key = RedisKey.getRedisAlarmKey(receiverId) + val json = objectMapper.writeValueAsString(savedAlarmMessage) + redisTemplate.opsForList().leftPush(key, json) + } + + fun getAlarms(memberId : Long, recentDays : Long) : List { + val key = RedisKey.getRedisAlarmKey(memberId) + val allJson = redisTemplate.opsForList().range(key, 0, -1) ?: return emptyList() + val now = LocalDateTime.now() + val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME + val cutoff = now.minusDays(recentDays) + + val validAlarms = mutableListOf() + + allJson.forEach { json -> + val alarm = runCatching { + objectMapper.readValue(json, SaveAlarmMessage::class.java) + }.getOrNull() ?: return@forEach + + runCatching { LocalDateTime.parse(alarm.timestamp, formatter) } + .getOrNull() + ?.takeIf { it.isAfter(cutoff) } + ?.let { validAlarms.add(alarm) } + ?: run { + redisTemplate.opsForList().remove(key, 1, json) + } + } + + return validAlarms + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/alarm/service/StatusAlarmService.kt b/src/main/kotlin/gomushin/backend/alarm/service/StatusAlarmService.kt new file mode 100644 index 00000000..105025a9 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/service/StatusAlarmService.kt @@ -0,0 +1,63 @@ +package gomushin.backend.alarm.service + +import gomushin.backend.alarm.util.MessageParsingUtil +import gomushin.backend.alarm.value.RedirectURL +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.value.Emotion +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service + +@Service +class StatusAlarmService ( + private val fcmService: FCMService, + private val notificationRedisService: NotificationRedisService, + private val redirectURL: RedirectURL +) { + private val statusMessage: Map> = mapOf( + Emotion.MISS to listOf( + "보고싶다는 마음이 도착했어요.+오늘은 짧은 안부라도 건네볼까요?", + "보고 싶다고 표현하는 건 용기예요.+작은 마음이 큰 위로가 될 거예요" + ), + Emotion.TIRED to listOf( + "오늘 많이 힘들었나봐요.+따뜻한 말 한마디가 큰 힘이 돼요", + "오늘 피곤한 날이래요.+연인에게 따뜻한 응원 어때요?" + ), + Emotion.SAD to listOf( + "서운한 마음이 들었대요.+연인과 진심 어린 대화 어때요?", + "연인의 마음이 무거운가봐요.+따뜻한 공감이 필요해요" + ), + Emotion.HAPPY to listOf( + "기분 좋은 하루를 함께 나눠보세요.+당신의 행복이 전해질 거예요.", + "좋은 일이 있었대요!+축하해주고 같이 기뻐해주세요💛" + ), + Emotion.ANGRY to listOf( + "연인이 짜증나는 일이 있었대요.+들어주는 것도 큰 위로가 될거예요", + "OO님 오늘 좀 힘들었나 봐요.+오늘 전화통화 어때요?" + ), + Emotion.WORRY to listOf( + "당신을 걱정하는 마음이 담겼어요.+잠깐 안부를 전해주면 어떨까요?", + "연인이 당신을 걱정해요.+연인의 마음 한쪽이 조금 무거웠대요." + ), + Emotion.COMMON to listOf( + "연인이 평범한 일상을 보냈대요.+안부를 전하는 일상이 관계를 지켜줄 거예요.", + "감정의 큰 파도는 없지만,+OO님의 하루를 들어줄 누군가가 있다면 좋겠대요." + ) + ) + + @Async + fun sendStatusAlarm(sender : Member, receiver : Member, emotion : Emotion) { + val notificationContent = + statusMessage[emotion]?.random() + ?: throw BadRequestException("sarangggun.member.not-exist-emoji") + val (title, content) = MessageParsingUtil.parse(notificationContent) + val sendContent = if (content.contains("OO")) { + content.replace("OO", sender.nickname) + } else { + content + } + val token = receiver.fcmToken + notificationRedisService.saveAlarm(title, sendContent, receiver.id) + fcmService.sendMessageTo(token, title, sendContent, redirectURL.main) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/alarm/util/MessageParsingUtil.kt b/src/main/kotlin/gomushin/backend/alarm/util/MessageParsingUtil.kt new file mode 100644 index 00000000..cce92fc2 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/util/MessageParsingUtil.kt @@ -0,0 +1,10 @@ +package gomushin.backend.alarm.util + +object MessageParsingUtil { + fun parse(notificationContent : String) : Pair { + val parts = notificationContent.split("+") + val title = parts[0] + val content = parts[1] + return title to content + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/alarm/value/RedirectURL.kt b/src/main/kotlin/gomushin/backend/alarm/value/RedirectURL.kt new file mode 100644 index 00000000..0b056bc7 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/alarm/value/RedirectURL.kt @@ -0,0 +1,11 @@ +package gomushin.backend.alarm.value + +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.stereotype.Component + +@Component +@ConfigurationProperties(prefix = "fcm.redirect") +data class RedirectURL ( + var main: String = "", + var dday: String = "" +) \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/auth/domain/service/AuthService.kt b/src/main/kotlin/gomushin/backend/auth/domain/service/AuthService.kt new file mode 100644 index 00000000..fd011682 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/auth/domain/service/AuthService.kt @@ -0,0 +1,38 @@ +package gomushin.backend.auth.domain.service + +import jakarta.servlet.http.Cookie +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service + +@Service +class AuthService( + @Value("\${cookie.domain}") + private val cookieDomain: String +) { + companion object { + private const val AT_PREFIX = "access_token" + private const val RT_PREFIX = "refresh_token" + } + + fun logout(response: HttpServletResponse) { + val expiredAccess = Cookie(AT_PREFIX, "").apply { + path = "/" + domain = cookieDomain + isHttpOnly = true + secure = true + maxAge = 0 + } + + val expiredRefresh = Cookie(RT_PREFIX, "").apply { + path = "/" + domain = cookieDomain + isHttpOnly = true + secure = true + maxAge = 0 + } + + response.addCookie(expiredAccess) + response.addCookie(expiredRefresh) + } +} diff --git a/src/main/kotlin/gomushin/backend/auth/facade/LogoutFacade.kt b/src/main/kotlin/gomushin/backend/auth/facade/LogoutFacade.kt new file mode 100644 index 00000000..423768ff --- /dev/null +++ b/src/main/kotlin/gomushin/backend/auth/facade/LogoutFacade.kt @@ -0,0 +1,26 @@ +package gomushin.backend.auth.facade + +import gomushin.backend.auth.domain.service.AuthService +import gomushin.backend.core.jwt.infrastructure.TokenService +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component + +@Component +class LogoutFacade( + private val authService: AuthService, + private val tokenService: TokenService, +) { + fun logout( + request: HttpServletRequest, + response: HttpServletResponse + ) { + val refreshToken = request.cookies?.find { it.name == "refresh_token" }?.value + + if (refreshToken != null) { + tokenService.deleteRefreshToken(refreshToken) + } + + authService.logout(response) + } +} diff --git a/src/main/kotlin/gomushin/backend/auth/facade/ReissueFacade.kt b/src/main/kotlin/gomushin/backend/auth/facade/ReissueFacade.kt new file mode 100644 index 00000000..101d3c03 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/auth/facade/ReissueFacade.kt @@ -0,0 +1,28 @@ +package gomushin.backend.auth.facade + +import gomushin.backend.core.configuration.cookie.CookieService +import gomushin.backend.core.jwt.infrastructure.TokenService +import gomushin.backend.member.domain.service.MemberService +import jakarta.servlet.http.HttpServletResponse +import org.springframework.stereotype.Component + +@Component +class ReissueFacade( + private val tokenService: TokenService, + private val memberService: MemberService, + private val cookieService: CookieService +){ + fun reissue(refreshToken : String, response: HttpServletResponse) { + tokenService.validateToken(refreshToken) + val userId = tokenService.getRefreshTokenValue(refreshToken) + tokenService.deleteRefreshToken(refreshToken) + val user = memberService.getById(userId) + val newAccessToken = tokenService.provideAccessToken(userId, user.role.name) + val newRefreshToken = tokenService.provideRefreshToken() + tokenService.upsertRefresh(userId, newRefreshToken, tokenService.getTokenDuration(newAccessToken)) + val accessCookie = cookieService.createCookie("access_token", newAccessToken) + val refreshCookie = cookieService.createCookie("refresh_token", newRefreshToken) + response.addHeader("Set-Cookie", accessCookie.toString()) + response.addHeader("Set-Cookie", refreshCookie.toString()) + } +} diff --git a/src/main/kotlin/gomushin/backend/auth/presentation/ApiPath.kt b/src/main/kotlin/gomushin/backend/auth/presentation/ApiPath.kt new file mode 100644 index 00000000..828f99e7 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/auth/presentation/ApiPath.kt @@ -0,0 +1,7 @@ +package gomushin.backend.auth.presentation + +object ApiPath { + const val REISSUE = "/v1/auth/reissue" + + const val LOGOUT = "/v1/auth/logout" +} diff --git a/src/main/kotlin/gomushin/backend/auth/presentation/LogoutController.kt b/src/main/kotlin/gomushin/backend/auth/presentation/LogoutController.kt new file mode 100644 index 00000000..ca391c35 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/auth/presentation/LogoutController.kt @@ -0,0 +1,30 @@ +package gomushin.backend.auth.presentation + +import gomushin.backend.auth.facade.LogoutFacade +import gomushin.backend.core.common.web.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "로그아웃", description = "LogoutController") +class LogoutController( + private val logoutFacade: LogoutFacade +) { + + @ResponseStatus(HttpStatus.NO_CONTENT) + @PostMapping(ApiPath.LOGOUT) + @Operation(summary = "로그아웃", description = "logout") + fun logout( + request: HttpServletRequest, + response: HttpServletResponse, + ):ApiResponse { + logoutFacade.logout(request, response) + return ApiResponse.success(true) + } +} diff --git a/src/main/kotlin/gomushin/backend/auth/presentation/ReissueController.kt b/src/main/kotlin/gomushin/backend/auth/presentation/ReissueController.kt new file mode 100644 index 00000000..d7d5ed02 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/auth/presentation/ReissueController.kt @@ -0,0 +1,26 @@ +package gomushin.backend.auth.presentation + +import gomushin.backend.auth.facade.ReissueFacade +import gomushin.backend.core.common.web.response.ApiResponse +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.* + +@RestController +@Tag(name = "토큰 재발급", description = "ReissueController") +class ReissueController ( + private val reissueFacade: ReissueFacade +) { + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(ApiPath.REISSUE) + @Operation(summary = "토큰 재발급", description = "reissue") + fun reissue( + @CookieValue("refresh_token") refreshToken: String, + response : HttpServletResponse + ): ApiResponse { + reissueFacade.reissue(refreshToken, response) + return ApiResponse.success(true) + } +} diff --git a/src/main/kotlin/gomushin/backend/core/CustomUserDetails.kt b/src/main/kotlin/gomushin/backend/core/CustomUserDetails.kt new file mode 100644 index 00000000..4b40a0b8 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/CustomUserDetails.kt @@ -0,0 +1,51 @@ +package gomushin.backend.core + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.repository.CoupleRepository +import gomushin.backend.member.domain.entity.Member +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.core.userdetails.UserDetails + +class CustomUserDetails( + private val member: Member, + private val coupleRepository: CoupleRepository, +) : UserDetails { + override fun getAuthorities(): MutableCollection { + return mutableListOf(SimpleGrantedAuthority("ROLE_${member.role.name}")) + } + + override fun getPassword(): String { + return "" + } + + override fun getUsername(): String { + return member.nickname + } + + override fun isAccountNonExpired(): Boolean { + return true + } + + override fun isAccountNonLocked(): Boolean { + return true + } + + override fun isCredentialsNonExpired(): Boolean { + return true + } + + override fun isEnabled(): Boolean { + return true + } + + fun getId(): Long { + return member.id + } + + fun getCouple(): Couple { + return coupleRepository.findByMemberId(getId()) + ?: throw BadRequestException("saranggun.couple.not-connected") + } +} diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/DistributedCacheOnly.kt b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/DistributedCacheOnly.kt new file mode 100644 index 00000000..102952ac --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/DistributedCacheOnly.kt @@ -0,0 +1,8 @@ +package gomushin.backend.core.common.cache.annotation.cacheable + +import org.springframework.cache.annotation.Cacheable + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +@Cacheable("distributedCache", cacheManager = "distributedCacheManager") +annotation class DistributedCacheOnly diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/LocalCacheOnly.kt b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/LocalCacheOnly.kt new file mode 100644 index 00000000..9fde046e --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/LocalCacheOnly.kt @@ -0,0 +1,8 @@ +package gomushin.backend.core.common.cache.annotation.cacheable + +import org.springframework.cache.annotation.Cacheable + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +@Cacheable("localCache", cacheManager = "localCacheManager") +annotation class LocalCacheOnly diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/MultiLayerCacheApply.kt b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/MultiLayerCacheApply.kt new file mode 100644 index 00000000..d96218e3 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/cacheable/MultiLayerCacheApply.kt @@ -0,0 +1,8 @@ +package gomushin.backend.core.common.cache.annotation.cacheable + +import org.springframework.cache.annotation.Cacheable + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +@Cacheable("multiLayerCache", cacheManager = "multiLayerCacheManager") +annotation class MultiLayerCacheApply diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/DistributedCacheEvict.kt b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/DistributedCacheEvict.kt new file mode 100644 index 00000000..e7e70133 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/DistributedCacheEvict.kt @@ -0,0 +1,8 @@ +package gomushin.backend.core.common.cache.annotation.evict + +import org.springframework.cache.annotation.CacheEvict + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +@CacheEvict("distributedCache", cacheManager = "distributedCacheManager", allEntries = true) +annotation class DistributedCacheEvict diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/LocalCacheEvict.kt b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/LocalCacheEvict.kt new file mode 100644 index 00000000..0dd162ff --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/LocalCacheEvict.kt @@ -0,0 +1,8 @@ +package gomushin.backend.core.common.cache.annotation.evict + +import org.springframework.cache.annotation.CacheEvict + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +@CacheEvict("localCache", cacheManager = "localCacheManager", allEntries = true) +annotation class LocalCacheEvict diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/MultiLayerCacheEvict.kt b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/MultiLayerCacheEvict.kt new file mode 100644 index 00000000..170cd504 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/annotation/evict/MultiLayerCacheEvict.kt @@ -0,0 +1,8 @@ +package gomushin.backend.core.common.cache.annotation.evict + +import org.springframework.cache.annotation.CacheEvict + +@Retention(AnnotationRetention.RUNTIME) +@Target(AnnotationTarget.FUNCTION) +@CacheEvict("multiLayerCache", cacheManager = "multiLayerCacheManager", allEntries = true) +annotation class MultiLayerCacheEvict diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/configuration/DistributedCacheConfiguration.kt b/src/main/kotlin/gomushin/backend/core/common/cache/configuration/DistributedCacheConfiguration.kt new file mode 100644 index 00000000..3c1b10f0 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/configuration/DistributedCacheConfiguration.kt @@ -0,0 +1,40 @@ +package gomushin.backend.core.common.cache.configuration + +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.cache.CacheManager +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.cache.RedisCacheManager +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer +import org.springframework.data.redis.serializer.RedisSerializationContext +import org.springframework.data.redis.serializer.StringRedisSerializer +import java.time.Duration + +@Configuration +@EnableCaching +class DistributedCacheConfiguration { + + @Bean + fun distributedCacheManager( + redisConnectionFactory: RedisConnectionFactory, + redisCacheConfiguration: RedisCacheConfiguration + ): CacheManager = RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults(redisCacheConfiguration) + .build() + + @Bean + fun redisCacheConfiguration(objectMapper: ObjectMapper): RedisCacheConfiguration { + val serializer = GenericJackson2JsonRedisSerializer(objectMapper) + return RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(serializer) + ) + .entryTtl(Duration.ofMinutes(10)) + } +} diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/configuration/LocalCacheConfiguration.kt b/src/main/kotlin/gomushin/backend/core/common/cache/configuration/LocalCacheConfiguration.kt new file mode 100644 index 00000000..4a9ec4ea --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/configuration/LocalCacheConfiguration.kt @@ -0,0 +1,27 @@ +package gomushin.backend.core.common.cache.configuration + +import com.github.benmanes.caffeine.cache.Caffeine +import org.springframework.cache.annotation.EnableCaching +import org.springframework.context.annotation.Configuration +import org.springframework.cache.CacheManager +import org.springframework.cache.caffeine.CaffeineCacheManager +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Primary +import java.time.Duration + +@Configuration +@EnableCaching +class LocalCacheConfiguration { + + @Primary + @Bean + fun localCacheManager(): CacheManager { + val caffeineCacheManager = CaffeineCacheManager() + caffeineCacheManager.setCaffeine( + Caffeine.newBuilder() + .maximumSize(100) + .expireAfterWrite(Duration.ofMinutes(5)) + ) + return caffeineCacheManager + } +} diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/configuration/MultiLayerCache.kt b/src/main/kotlin/gomushin/backend/core/common/cache/configuration/MultiLayerCache.kt new file mode 100644 index 00000000..a6f71cfc --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/configuration/MultiLayerCache.kt @@ -0,0 +1,65 @@ +package gomushin.backend.core.common.cache.configuration + +import org.springframework.cache.Cache +import java.util.concurrent.Callable + +class MultiLayerCache( + val localCache: Cache, + val distributedCache: Cache, +) : Cache { + override fun getName(): String { + return localCache.name + } + + override fun getNativeCache(): Any { + return localCache.nativeCache + } + + override fun get(key: Any): Cache.ValueWrapper? { + var value = localCache.get(key) + if (value == null) { + value = distributedCache.get(key) + if (value != null) { + localCache.put(key, value.get()) + } + } + return value + } + + override fun get(key: Any, type: Class?): T? { + var value = localCache.get(key, type) + if (value == null) { + value = distributedCache.get(key, type) + if (value != null) { + localCache.put(key, value) + } + } + return value + } + + override fun get(key: Any, valueLoader: Callable): T? { + var value = localCache.get(key, valueLoader) + if (value == null) { + value = distributedCache.get(key, valueLoader) + if (value != null) { + localCache.put(key, value) + } + } + return value + } + + override fun put(key: Any, value: Any?) { + distributedCache.put(key, value) + localCache.put(key, value) + } + + override fun evict(key: Any) { + localCache.evict(key) + distributedCache.evict(key) + } + + override fun clear() { + localCache.clear() + distributedCache.clear() + } +} diff --git a/src/main/kotlin/gomushin/backend/core/common/cache/configuration/MultiLayerCacheManager.kt b/src/main/kotlin/gomushin/backend/core/common/cache/configuration/MultiLayerCacheManager.kt new file mode 100644 index 00000000..c28c7dec --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/cache/configuration/MultiLayerCacheManager.kt @@ -0,0 +1,26 @@ +package gomushin.backend.core.common.cache.configuration + +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.cache.Cache +import org.springframework.cache.CacheManager +import org.springframework.stereotype.Component +import java.util.stream.Collectors +import java.util.stream.Stream + +@Component +class MultiLayerCacheManager( + @Qualifier("localCacheManager") private val localCacheManager: CacheManager, + @Qualifier("distributedCacheManager") private val distributedCacheManager: CacheManager +) : CacheManager { + + override fun getCache(name: String): Cache = MultiLayerCache( + localCacheManager.getCache("localCache")!!, + distributedCacheManager.getCache("distributedCache")!! + ) + + override fun getCacheNames(): MutableCollection = Stream.concat( + localCacheManager.cacheNames.stream(), + distributedCacheManager.cacheNames.stream() + ).collect(Collectors.toList()) + +} diff --git a/src/main/kotlin/gomushin/backend/core/common/support/SpringContextHolder.kt b/src/main/kotlin/gomushin/backend/core/common/support/SpringContextHolder.kt index 5ac49f39..d63d9f63 100644 --- a/src/main/kotlin/gomushin/backend/core/common/support/SpringContextHolder.kt +++ b/src/main/kotlin/gomushin/backend/core/common/support/SpringContextHolder.kt @@ -6,7 +6,7 @@ import org.springframework.stereotype.Component @Component object SpringContextHolder : ApplicationContextAware { - private lateinit var context: ApplicationContext + lateinit var context: ApplicationContext override fun setApplicationContext(applicationContext: ApplicationContext) { context = applicationContext diff --git a/src/main/kotlin/gomushin/backend/core/common/web/PageResponse.kt b/src/main/kotlin/gomushin/backend/core/common/web/PageResponse.kt new file mode 100644 index 00000000..2a5a2ecf --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/common/web/PageResponse.kt @@ -0,0 +1,23 @@ +package gomushin.backend.core.common.web + +import io.swagger.v3.oas.annotations.media.Schema +import org.springframework.data.domain.Page + +data class PageResponse( + @Schema(description = "페이지 응답") + val data: List, + @Schema(description = "전체 페이지 수") + val totalPages: Int, + @Schema(description = "마지막 페이지 여부") + val isLastPage: Boolean, +) { + companion object { + fun from( + page: Page + ): PageResponse = PageResponse( + data = page.content, + totalPages = page.totalPages, + isLastPage = page.isLast + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/core/common/web/response/ApiResponse.kt b/src/main/kotlin/gomushin/backend/core/common/web/response/ApiResponse.kt index 282e5172..2ca812d8 100644 --- a/src/main/kotlin/gomushin/backend/core/common/web/response/ApiResponse.kt +++ b/src/main/kotlin/gomushin/backend/core/common/web/response/ApiResponse.kt @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import gomushin.backend.core.common.web.response.exception.ApiError +import io.swagger.v3.oas.annotations.media.Schema import java.util.concurrent.CompletableFuture import java.util.function.Function import java.util.function.Supplier @@ -20,6 +21,7 @@ data class ApiResponse( val result: T? = null, @get:JsonProperty("error") + @Schema(hidden = true) val error: ApiError? = null, ) { init { diff --git a/src/main/kotlin/gomushin/backend/core/common/web/response/exception/ApiExceptionHandler.kt b/src/main/kotlin/gomushin/backend/core/common/web/response/exception/ApiExceptionHandler.kt index bf0d0250..f9d8c321 100644 --- a/src/main/kotlin/gomushin/backend/core/common/web/response/exception/ApiExceptionHandler.kt +++ b/src/main/kotlin/gomushin/backend/core/common/web/response/exception/ApiExceptionHandler.kt @@ -1,16 +1,19 @@ package gomushin.backend.core.common.web.response.exception import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.core.infrastructure.filter.logging.LoggingFilter +import org.slf4j.LoggerFactory import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.ExceptionHandler import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice class ApiExceptionHandler { - + private val log = LoggerFactory.getLogger(ApiExceptionHandler::class.java) @ExceptionHandler(ApiErrorException::class) fun handleApiErrorExtention(ex: ApiErrorException): ResponseEntity> { val status = ex.error.element.status + log.warn("[Error] errorStatus : {}, errorMessage : {}",ex.error.element.code.value, ex.error.element.message.resolved) return ResponseEntity.status(status.code).body(ApiResponse.error(ex.error)) } } diff --git a/src/main/kotlin/gomushin/backend/core/configuration/cookie/CookieService.kt b/src/main/kotlin/gomushin/backend/core/configuration/cookie/CookieService.kt new file mode 100644 index 00000000..7df45496 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/configuration/cookie/CookieService.kt @@ -0,0 +1,21 @@ +package gomushin.backend.core.configuration.cookie + +import org.springframework.beans.factory.annotation.Value +import org.springframework.http.ResponseCookie +import org.springframework.stereotype.Service + +@Service +class CookieService( + @Value("\${cookie.domain}") private val cookieDomain: String +){ + fun createCookie(key: String, value: String): ResponseCookie { + return ResponseCookie.from(key, value) + .path("/") + .httpOnly(true) + .secure(true) + .sameSite("None") + .domain(cookieDomain) + .maxAge(432000) + .build() + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/core/configuration/jackson/JacksonConfig.kt b/src/main/kotlin/gomushin/backend/core/configuration/jackson/JacksonConfig.kt new file mode 100644 index 00000000..b87eb4e8 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/configuration/jackson/JacksonConfig.kt @@ -0,0 +1,45 @@ +package gomushin.backend.core.configuration.jackson + +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.databind.SerializationFeature +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer +import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer +import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer +import com.fasterxml.jackson.module.kotlin.KotlinModule +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +@Configuration +class JacksonConfig { + companion object { + private const val DATE_TIME_FORMAT = "yyyy-MM-dd'T'HH:mm:ss" + private const val DATE_FORMAT = "yyyy-MM-dd" + private const val TIME_FORMAT = "HH:mm:ss.SSS" + private val LOCAL_DATETIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_FORMAT) + private val LOCAL_DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_FORMAT) + private val LOCAL_TIME_FORMATTER = DateTimeFormatter.ofPattern(TIME_FORMAT) + } + + @Bean + fun objectMapper(): ObjectMapper { + val javaTimeModule = JavaTimeModule().apply { + addSerializer(LocalDateTime::class.java, LocalDateTimeSerializer(LOCAL_DATETIME_FORMATTER)) + addSerializer(LocalDate::class.java, LocalDateSerializer(LOCAL_DATE_FORMATTER)) + addSerializer(LocalTime::class.java, LocalTimeSerializer(LOCAL_TIME_FORMATTER)) + } + + return jacksonObjectMapper().apply { + registerModules(javaTimeModule, KotlinModule.Builder().build()) + configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false) + configure(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, false) + configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/core/configuration/redis/RedisKey.kt b/src/main/kotlin/gomushin/backend/core/configuration/redis/RedisKey.kt new file mode 100644 index 00000000..c81f24de --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/configuration/redis/RedisKey.kt @@ -0,0 +1,17 @@ +package gomushin.backend.core.configuration.redis + +enum class RedisKey( + private val tag : String +) { + ALARM("alarm:"), + REFRESH_TOKEN("refresh_token:"); + + companion object { + fun getRedisAlarmKey(receiverId : Long) : String { + return ALARM.tag + receiverId + } + fun getRedisRefreshKey(refreshToken : String) : String { + return REFRESH_TOKEN.tag + refreshToken + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/core/configuration/restclient/RestClientConfiguration.kt b/src/main/kotlin/gomushin/backend/core/configuration/restclient/RestClientConfiguration.kt new file mode 100644 index 00000000..f5a35024 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/configuration/restclient/RestClientConfiguration.kt @@ -0,0 +1,22 @@ +package gomushin.backend.core.configuration.restclient + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpStatusCode +import org.springframework.web.client.RestClient + +@Configuration +class RestClientConfiguration { + + @Bean + fun restClient(): RestClient { + return RestClient.builder() + .defaultStatusHandler(HttpStatusCode::is4xxClientError) { request, response -> + throw IllegalStateException("4xx 에러 발생 ${response.statusCode}") + } + .defaultStatusHandler(HttpStatusCode::is5xxServerError) { request, response -> + throw IllegalStateException("5xx 에러 발생 ${response.statusCode}") + } + .build(); + } +} diff --git a/src/main/kotlin/gomushin/backend/core/configuration/s3/S3Configuration.kt b/src/main/kotlin/gomushin/backend/core/configuration/s3/S3Configuration.kt new file mode 100644 index 00000000..bb7c53be --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/configuration/s3/S3Configuration.kt @@ -0,0 +1,45 @@ +package gomushin.backend.core.configuration.s3 + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.core.client.config.ClientOverrideConfiguration +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.S3Configuration +import java.net.URI +import java.time.Duration + +@Configuration +class S3Configuration( + @Value("\${aws.s3.endpoint}") val endpoint: String, + @Value("\${aws.s3.accessKey}") val accessKey: String, + @Value("\${aws.s3.secretKey}") val secretKey: String, + @Value("\${aws.s3.region}") val region: String, +) { + @Bean + fun s3Client(): S3Client { + return S3Client.builder() + .endpointOverride(URI.create(endpoint)) + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + ) + ) + .region(Region.of(region)) + .serviceConfiguration( + S3Configuration.builder() + .pathStyleAccessEnabled(true) + .build() + ) + .overrideConfiguration( + ClientOverrideConfiguration.builder() + .apiCallTimeout(Duration.ofSeconds(30)) + .apiCallAttemptTimeout(Duration.ofSeconds(10)) + .build() + ) + .build() + } +} diff --git a/src/main/kotlin/gomushin/backend/core/configuration/security/CustomCorsConfiguration.kt b/src/main/kotlin/gomushin/backend/core/configuration/security/CustomCorsConfiguration.kt new file mode 100644 index 00000000..c87f431b --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/configuration/security/CustomCorsConfiguration.kt @@ -0,0 +1,36 @@ +package gomushin.backend.core.configuration.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.cors.CorsConfiguration +import org.springframework.web.cors.CorsConfigurationSource +import org.springframework.web.cors.UrlBasedCorsConfigurationSource + +@Configuration +class CustomCorsConfiguration { + + @Bean + fun corsConfigurationSource(): CorsConfigurationSource { + val configuration = CorsConfiguration() + configuration.allowedOrigins = + listOf( + "http://localhost:5173", + "https://localhost:5173", + "http://localhost:8080", + "https://frontend-sarang.vercel.app", + "https://vite.sarang-backend.o-r.kr:5173", + "https://sarang-backend.o-r.kr", + "https://www.sarangkkun.site", + "https://sarangkkun.site", + "https://vite.sarangkkun.site:5173", + ) + configuration.allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS") + configuration.allowedHeaders = listOf("*") + configuration.allowCredentials = true + configuration.exposedHeaders = listOf("Authorization", "Set-Cookie") + configuration.maxAge = 3600 + val source = UrlBasedCorsConfigurationSource() + source.registerCorsConfiguration("/**", configuration) + return source + } +} diff --git a/src/main/kotlin/gomushin/backend/core/configuration/security/SecurityConfiguration.kt b/src/main/kotlin/gomushin/backend/core/configuration/security/SecurityConfiguration.kt new file mode 100644 index 00000000..9a23186b --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/configuration/security/SecurityConfiguration.kt @@ -0,0 +1,111 @@ +package gomushin.backend.core.configuration.security + +import gomushin.backend.core.configuration.cookie.CookieService +import gomushin.backend.core.infrastructure.filter.CustomAuthenticationEntryPoint +import gomushin.backend.core.infrastructure.filter.JwtAuthenticationFilter +import gomushin.backend.core.jwt.infrastructure.TokenService +import gomushin.backend.core.oauth.handler.CustomAccessDeniedHandler +import gomushin.backend.core.oauth.handler.CustomSuccessHandler +import gomushin.backend.core.oauth.service.CustomOAuth2UserService +import gomushin.backend.core.service.CustomUserDetailsService +import gomushin.backend.couple.domain.repository.CoupleRepository +import gomushin.backend.member.domain.repository.MemberRepository +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter +import org.springframework.web.cors.CorsUtils + +@Configuration +@EnableWebSecurity +class SecurityConfiguration( + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val cookieService: CookieService, + @Value("\${member-redirect-url}") private val memberRedirectUrl: String, +) { + + @Bean + fun filterChain( + http: HttpSecurity, corsConfiguration: CustomCorsConfiguration, + customOAuth2UserService: CustomOAuth2UserService, coupleRepository: CoupleRepository, + customAccessDeniedHandler: CustomAccessDeniedHandler, + customAuthenticationEntryPoint: CustomAuthenticationEntryPoint, + ): SecurityFilterChain { + http + .csrf { + it.disable() + } + .cors { + it.configurationSource( + corsConfiguration.corsConfigurationSource() + ) + } + .formLogin { + it.disable() + } + .httpBasic { + it.disable() + } + .sessionManagement { + it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + } + .oauth2Login { oAuth2LoginConfigurer -> + oAuth2LoginConfigurer + .userInfoEndpoint { userInfoEndpointConfigurer -> + userInfoEndpointConfigurer + .userService(customOAuth2UserService) + } + .successHandler( + CustomSuccessHandler( + tokenService, + memberRepository, + cookieService, + memberRedirectUrl, + ) + ) + } + .exceptionHandling { + it.accessDeniedHandler(customAccessDeniedHandler) + it.authenticationEntryPoint(customAuthenticationEntryPoint) + } + .authorizeHttpRequests { + it.requestMatchers( + "/", + "/v1/member/my-info", + "/v1/auth/**", + "/v1/oauth/**", + "/oauth2/**", + "/oauth2/authorization/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**", + "/health", + "/swagger-ui/index.html", + "/favicon.ico", + "/error", + ).permitAll() + it.requestMatchers(CorsUtils::isPreFlightRequest,).permitAll() + it.requestMatchers("/v1/member/onboarding").hasRole("GUEST") + it.anyRequest().hasRole("MEMBER") + } + .addFilterBefore( + JwtAuthenticationFilter( + tokenService, + CustomUserDetailsService( + memberRepository, + coupleRepository, + ) + ), + UsernamePasswordAuthenticationFilter:: + class.java + ) + return http.build() + } +} diff --git a/src/main/kotlin/gomushin/backend/core/configuration/swagger/SwaggerConfiguration.kt b/src/main/kotlin/gomushin/backend/core/configuration/swagger/SwaggerConfiguration.kt index b9ccf2d5..40a311aa 100644 --- a/src/main/kotlin/gomushin/backend/core/configuration/swagger/SwaggerConfiguration.kt +++ b/src/main/kotlin/gomushin/backend/core/configuration/swagger/SwaggerConfiguration.kt @@ -1,42 +1,36 @@ package gomushin.backend.core.configuration.swagger +import io.swagger.v3.oas.models.Components import io.swagger.v3.oas.models.OpenAPI -import io.swagger.v3.oas.models.info.Contact -import io.swagger.v3.oas.models.info.Info -import io.swagger.v3.oas.models.info.License -import org.springdoc.core.models.GroupedOpenApi +import io.swagger.v3.oas.models.security.SecurityRequirement +import io.swagger.v3.oas.models.security.SecurityScheme +import io.swagger.v3.oas.models.servers.Server import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @Configuration -class SwaggerConfiguration { - @Bean - fun publicApi(): GroupedOpenApi { - return GroupedOpenApi.builder() - .group("base-service") - .pathsToMatch("/**") - .build() - } +class SwaggerConfiguration( + @Value("\${swagger.request-url}") + private val requestUrl: String, +) { @Bean - fun customOpenAPI( - @Value("\${application-description}") appDescription: String?, @Value( - "\${application-version}" - ) appVersion: String? - ): OpenAPI { - val contact = Contact() - contact.email = "abc29887@naver.com" - contact.name = "HOYEONG JEON" + fun openAPI(): OpenAPI { + val securityScheme = SecurityScheme() + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + + val securityRequirement = SecurityRequirement().addList("bearerAuth") + + val server = Server() + .url(requestUrl) + .description("Production Server") + return OpenAPI() - .info( - Info() - .title("사랑하는군 API") - .version(appVersion) - .description(appDescription) - .termsOfService("http://swagger.io/terms/") - .license(License().name("Apache 2.0").url("http://springdoc.org")) - .contact(contact) - ) + .components(Components().addSecuritySchemes("bearerAuth", securityScheme)) + .servers(listOf(server)) + .addSecurityItem(securityRequirement) } } diff --git a/src/main/kotlin/gomushin/backend/core/event/dto/S3DeleteEvent.kt b/src/main/kotlin/gomushin/backend/core/event/dto/S3DeleteEvent.kt new file mode 100644 index 00000000..9f935b0f --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/event/dto/S3DeleteEvent.kt @@ -0,0 +1,5 @@ +package gomushin.backend.core.event.dto + +data class S3DeleteEvent( + val pictureUrls: List +) diff --git a/src/main/kotlin/gomushin/backend/core/event/listener/S3PicturesDeleteEventListener.kt b/src/main/kotlin/gomushin/backend/core/event/listener/S3PicturesDeleteEventListener.kt new file mode 100644 index 00000000..5b28e24b --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/event/listener/S3PicturesDeleteEventListener.kt @@ -0,0 +1,22 @@ +package gomushin.backend.core.event.listener + +import gomushin.backend.core.service.S3Service +import gomushin.backend.core.event.dto.S3DeleteEvent +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class S3PicturesDeleteEventListener( + private val s3Service: S3Service, +) { + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: S3DeleteEvent) { + event.pictureUrls.forEach { pictureUrl -> + s3Service.deleteFile(pictureUrl) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/core/infrastructure/filter/CustomAuthenticationEntryPoint.kt b/src/main/kotlin/gomushin/backend/core/infrastructure/filter/CustomAuthenticationEntryPoint.kt new file mode 100644 index 00000000..8cf5e04f --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/infrastructure/filter/CustomAuthenticationEntryPoint.kt @@ -0,0 +1,36 @@ +package gomushin.backend.core.infrastructure.filter + +import com.fasterxml.jackson.databind.ObjectMapper +import gomushin.backend.core.common.web.response.ExtendedHttpStatus +import gomushin.backend.core.common.web.response.exception.ErrorCodeResolvingApiErrorException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.http.MediaType +import org.springframework.security.core.AuthenticationException +import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.stereotype.Component + +@Component +class CustomAuthenticationEntryPoint(private val objectMapper: ObjectMapper): AuthenticationEntryPoint { + override fun commence( + request: HttpServletRequest?, + response: HttpServletResponse?, + authException: AuthenticationException? + ) { + + if(response?.isCommitted == true) return + + response?.contentType = MediaType.APPLICATION_JSON_VALUE + response?.status = HttpServletResponse.SC_UNAUTHORIZED + response?.characterEncoding = "UTF-8" + + val exception = ErrorCodeResolvingApiErrorException( + ExtendedHttpStatus.UNAUTHORIZED, + "sarangggun.auth.unauthorized" + ) + + response?.writer?.write( + objectMapper.writeValueAsString(exception.error) + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/core/infrastructure/filter/JwtAuthenticationFilter.kt b/src/main/kotlin/gomushin/backend/core/infrastructure/filter/JwtAuthenticationFilter.kt new file mode 100644 index 00000000..a16843a4 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/infrastructure/filter/JwtAuthenticationFilter.kt @@ -0,0 +1,80 @@ +package gomushin.backend.core.infrastructure.filter + +import gomushin.backend.core.jwt.infrastructure.TokenService +import gomushin.backend.core.service.CustomUserDetailsService +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtAuthenticationFilter( + private val tokenService: TokenService, + private val customUserDetailsService: CustomUserDetailsService +) : OncePerRequestFilter() { + + companion object { + private const val AT_IN_COOKIE = "access_token" + private const val AUTHORIZATION_HEADER = "Authorization" + private const val BEARER_PREFIX = "Bearer " + } + + override fun shouldNotFilter(request: HttpServletRequest): Boolean { + val excludedPaths = listOf( + "/v1/auth", "/v1/oauth", "/swagger", "/v3/api-docs", "/api-docs" + ) + return excludedPaths.any { path -> + request.requestURI.startsWith(path) || request.requestURI == path + } + } + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + + val accessToken = getCookieValue(request, AT_IN_COOKIE) ?: getAccessTokenFromHeader(request) + + when { + accessToken != null && tokenService.validateToken(accessToken) -> { + applyAuthentication(accessToken) + } + + else -> { + // TODO: 에러 응답 처리 + SecurityContextHolder.clearContext() + } + } + + filterChain.doFilter(request, response) + } + + private fun applyAuthentication(token: String) { + val userId = tokenService.getMemberIdFromToken(token) + val userDetails = customUserDetailsService.loadUserById(userId) + + val auth = UsernamePasswordAuthenticationToken( + userDetails, + null, + userDetails.authorities + ) + SecurityContextHolder.getContext().authentication = auth + } + + private fun getCookieValue(request: HttpServletRequest, name: String): String? { + return request.cookies?.firstOrNull { it.name == name }?.value + } + + private fun getAccessTokenFromHeader(request: HttpServletRequest): String? { + val authorizationHeader = request.getHeader(AUTHORIZATION_HEADER) + return if (authorizationHeader != null && authorizationHeader.startsWith(BEARER_PREFIX)) { + authorizationHeader.substring(7) + } else { + null + } + } +} diff --git a/src/main/kotlin/gomushin/backend/core/infrastructure/filter/logging/CachedBodyHttpServletRequest.kt b/src/main/kotlin/gomushin/backend/core/infrastructure/filter/logging/CachedBodyHttpServletRequest.kt new file mode 100644 index 00000000..dd253e6d --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/infrastructure/filter/logging/CachedBodyHttpServletRequest.kt @@ -0,0 +1,27 @@ +package gomushin.backend.core.infrastructure.filter.logging + +import jakarta.servlet.ReadListener +import jakarta.servlet.ServletInputStream +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletRequestWrapper +import java.io.BufferedReader +import java.io.ByteArrayInputStream +import java.io.InputStreamReader + +class CachedBodyHttpServletRequest(request: HttpServletRequest) : HttpServletRequestWrapper(request) { + private val cachedBody: ByteArray = request.inputStream.readBytes() + + override fun getInputStream(): ServletInputStream { + val byteArrayInputStream = ByteArrayInputStream(cachedBody) + return object : ServletInputStream() { + override fun isFinished() = byteArrayInputStream.available() == 0 + override fun isReady() = true + override fun setReadListener(readListener: ReadListener?) {} + override fun read(): Int = byteArrayInputStream.read() + } + } + + override fun getReader(): BufferedReader { + return BufferedReader(InputStreamReader(inputStream)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/core/infrastructure/filter/logging/LoggingFilter.kt b/src/main/kotlin/gomushin/backend/core/infrastructure/filter/logging/LoggingFilter.kt new file mode 100644 index 00000000..3cd03e67 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/infrastructure/filter/logging/LoggingFilter.kt @@ -0,0 +1,49 @@ +package gomushin.backend.core.infrastructure.filter.logging + +import gomushin.backend.core.CustomUserDetails +import jakarta.servlet.Filter +import jakarta.servlet.FilterChain +import jakarta.servlet.ServletRequest +import jakarta.servlet.ServletResponse +import jakarta.servlet.http.HttpServletRequest +import org.slf4j.LoggerFactory +import org.springframework.security.authentication.AnonymousAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.stereotype.Component + +@Component +class LoggingFilter : Filter { + + private val log = LoggerFactory.getLogger(LoggingFilter::class.java) + + override fun doFilter(request: ServletRequest, response: ServletResponse, chain: FilterChain) { + if (request is HttpServletRequest) { + if(request.contentType?.startsWith("multipart/") == true) { + chain.doFilter(request, response) + return + } + val wrappedRequest = CachedBodyHttpServletRequest(request) + + val url = wrappedRequest.requestURI + val method = wrappedRequest.method + val body = runCatching { wrappedRequest.reader.readLines().joinToString("") }.getOrNull() + + val authentication = SecurityContextHolder.getContext().authentication + + val userId: Long? = when { + authentication == null || !authentication.isAuthenticated || + authentication is AnonymousAuthenticationToken -> null + authentication.principal is CustomUserDetails -> { + (authentication.principal as CustomUserDetails).getId() + } + else -> null + } + + log.info("[REQUEST LOG] userId={}, URL={}, Method={}, Body={}", userId, url, method, body) + + chain.doFilter(wrappedRequest, response) + } else { + chain.doFilter(request, response) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/JwtProperties.kt b/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/JwtProperties.kt new file mode 100644 index 00000000..bdcfcb12 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/JwtProperties.kt @@ -0,0 +1,13 @@ +package gomushin.backend.core.jwt.infrastructure + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties("jwt") +data class JwtProperties( + val issuer: String, + val audience: String, + val secretKey: String, + val accessTokenExpiration: Long, + val refreshTokenExpiration: Long +) + diff --git a/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/TokenService.kt b/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/TokenService.kt new file mode 100644 index 00000000..a7e65902 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/TokenService.kt @@ -0,0 +1,96 @@ +package gomushin.backend.core.jwt.infrastructure + +import gomushin.backend.core.configuration.redis.RedisKey +import gomushin.backend.core.infrastructure.exception.BadRequestException +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.apache.logging.log4j.LogManager +import org.apache.logging.log4j.Logger +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Component +import java.time.Duration +import java.util.* + +@Component +class TokenService( + jwtProperties: JwtProperties, + private val redisTemplate: StringRedisTemplate +) { + companion object { + private val logger: Logger = LogManager.getLogger(TokenService::class.java) + } + + val ISSUER = jwtProperties.issuer + val AUDIENCE = jwtProperties.audience + val SECRET_KEY = Keys.hmacShaKeyFor(jwtProperties.secretKey.toByteArray(Charsets.UTF_8)) + val ACCESS_TOKEN_EXPIRATION = jwtProperties.accessTokenExpiration + val REFRESH_TOKEN_EXPIRATION = jwtProperties.refreshTokenExpiration + + fun provideAccessToken(userId: Long, role: String): String { + return createToken(userId, role, ACCESS_TOKEN_EXPIRATION, Type.ACCESS) + } + + fun getMemberIdFromToken(token: String): Long { + val subject = getSubject(token) + return subject.toLong() + } + + fun validateToken(token: String): Boolean { + try { + Jwts.parser().verifyWith(SECRET_KEY).build().parseSignedClaims(token) + return true + } catch (e: Exception) { + return false + } + } + + fun provideRefreshToken() : String { + return createToken(0, "", REFRESH_TOKEN_EXPIRATION, Type.REFRESH) + } + + fun getTokenDuration(token: String): Duration { + val now = Date(System.currentTimeMillis()) + return Duration.between(now.toInstant(), getTokenExpiration(token).toInstant()) + } + + private fun getTokenExpiration(token: String) : Date { + return Jwts.parser().verifyWith(SECRET_KEY).build().parseSignedClaims(token).payload.expiration + } + + private fun createToken(userId: Long, role: String, expiration: Long, type: Type): String { + val expirationMs = expiration * 60 * 1000 + val expiryDate = Date(System.currentTimeMillis() + expirationMs) + + return Jwts.builder() + .issuer(ISSUER) + .audience().add(AUDIENCE).and() + .subject(userId.toString()) + .claim("type", type.name) + .claim("role", role) + .issuedAt(Date()) + .expiration(expiryDate) + .signWith(SECRET_KEY) + .compact() + } + + fun getSubject(token: String): String { + return Jwts.parser().verifyWith(SECRET_KEY).build().parseSignedClaims(token).payload.subject + } + + fun upsertRefresh(userId : Long, refreshToken : String, duration: Duration) { + val key = RedisKey.getRedisRefreshKey(refreshToken) + redisTemplate.opsForValue().set(key, userId.toString(), duration) + } + + fun getRefreshTokenValue(refreshToken: String) : Long { + val key = RedisKey.getRedisRefreshKey(refreshToken) + val value = redisTemplate.opsForValue().get(key) + ?: throw BadRequestException("sarangggun.auth.invalid-refresh") + return value.toLong() + } + + fun deleteRefreshToken(refreshToken: String) { + val key = RedisKey.getRedisRefreshKey(refreshToken) + redisTemplate.delete(key) + } +} diff --git a/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/Type.kt b/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/Type.kt new file mode 100644 index 00000000..09b7f57a --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/jwt/infrastructure/Type.kt @@ -0,0 +1,6 @@ +package gomushin.backend.core.jwt.infrastructure + +enum class Type { + ACCESS, REFRESH +} + diff --git a/src/main/kotlin/gomushin/backend/core/oauth/CustomOAuth2User.kt b/src/main/kotlin/gomushin/backend/core/oauth/CustomOAuth2User.kt new file mode 100644 index 00000000..9688431a --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/oauth/CustomOAuth2User.kt @@ -0,0 +1,34 @@ +package gomushin.backend.core.oauth + +import gomushin.backend.core.oauth.dto.UserDTO +import org.springframework.security.core.GrantedAuthority +import org.springframework.security.oauth2.core.user.OAuth2User + +class CustomOAuth2User( + private val userDto: UserDTO +) : OAuth2User { + + override fun getName(): String { + return userDto.name + } + + override fun getAttributes(): MutableMap { + return mutableMapOf() + } + + override fun getAuthorities(): MutableCollection { + return mutableListOf() + } + + fun getEmail(): String { + return userDto.email ?: "" + } + + fun getUserId(): Long { + return userDto.userId + } + + fun getRole() : String { + return userDto.role + } +} diff --git a/src/main/kotlin/gomushin/backend/core/oauth/dto/KakaoResponse.kt b/src/main/kotlin/gomushin/backend/core/oauth/dto/KakaoResponse.kt new file mode 100644 index 00000000..d25bb717 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/oauth/dto/KakaoResponse.kt @@ -0,0 +1,35 @@ +package gomushin.backend.core.oauth.dto + +import gomushin.backend.core.infrastructure.exception.BadRequestException + + +class KakaoResponse(private val attributes: Map) : OAuth2Response { + override fun getProviderId(): String { + return attributes["id"].toString() + } + + override fun getEmail(): String { + val kakaoAccount = attributes["kakao_account"] as? Map<*, *> + + return kakaoAccount?.get("email")?.toString() + ?: throw BadRequestException("sarangggun.oauth.missing-email") + } + + override fun getName(): String { + val properties = attributes["properties"] as? Map<*, *> + properties?.get("nickname")?.let { + return it.toString() + } + + val kakaoAccount = attributes["kakao_account"] as? Map<*, *> + val profile = kakaoAccount?.get("profile") as? Map<*, *> + return profile?.get("nickname")?.toString() + ?: throw BadRequestException("sarangggun.oauth.missing-nickname") + } + + fun getProfileImage(): String? { + val kakaoAccount = attributes["kakao_account"] as? Map<*, *> + val profile = kakaoAccount?.get("profile") as? Map<*, *> + return profile?.get("profile_image_url") as? String + } +} diff --git a/src/main/kotlin/gomushin/backend/core/oauth/dto/OAuth2Response.kt b/src/main/kotlin/gomushin/backend/core/oauth/dto/OAuth2Response.kt new file mode 100644 index 00000000..2f24454a --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/oauth/dto/OAuth2Response.kt @@ -0,0 +1,9 @@ +package gomushin.backend.core.oauth.dto + +interface OAuth2Response { + fun getProviderId(): String + + fun getEmail(): String + + fun getName(): String +} diff --git a/src/main/kotlin/gomushin/backend/core/oauth/dto/UserDTO.kt b/src/main/kotlin/gomushin/backend/core/oauth/dto/UserDTO.kt new file mode 100644 index 00000000..f45178db --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/oauth/dto/UserDTO.kt @@ -0,0 +1,17 @@ +package gomushin.backend.core.oauth.dto + +data class UserDTO( + val registrationId: String, + + val role: String, + + val name: String, + + val username: String, + + val email: String? = null, + + val profileImage: String? = null, + + val userId: Long, +) diff --git a/src/main/kotlin/gomushin/backend/core/oauth/handler/CustomAccessDeniedHandler.kt b/src/main/kotlin/gomushin/backend/core/oauth/handler/CustomAccessDeniedHandler.kt new file mode 100644 index 00000000..eee9a876 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/oauth/handler/CustomAccessDeniedHandler.kt @@ -0,0 +1,48 @@ +package gomushin.backend.core.oauth.handler + +import com.fasterxml.jackson.databind.ObjectMapper +import gomushin.backend.core.common.web.response.ExtendedHttpStatus +import gomushin.backend.core.common.web.response.exception.ErrorCodeResolvingApiErrorException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.LoggerFactory +import org.springframework.http.MediaType +import org.springframework.security.access.AccessDeniedException +import org.springframework.security.web.access.AccessDeniedHandler +import org.springframework.stereotype.Component + + +@Component +class CustomAccessDeniedHandler(private val objectMapper: ObjectMapper) : AccessDeniedHandler { + private val logger = LoggerFactory.getLogger(CustomAccessDeniedHandler::class.java) + override fun handle( + request: HttpServletRequest, + response: HttpServletResponse, + accessDeniedException: AccessDeniedException + ) { + if (response.isCommitted) return + + response.contentType = MediaType.APPLICATION_JSON_VALUE + response.status = HttpServletResponse.SC_FORBIDDEN + response.characterEncoding = "UTF-8" + + logger.error("Access Denied: ${accessDeniedException.message}") + accessDeniedException.printStackTrace() + + val errorCode = if (request.requestURI.contains("/v1/member/onboarding")) { + "sarangggun.auth.guest-only" + } else { + "sarangggun.auth.member-only" + } + + val exception = ErrorCodeResolvingApiErrorException( + ExtendedHttpStatus.FORBIDDEN, + errorCode + ) + response.writer.write( + objectMapper.writeValueAsString(exception.error) + ) + + } +} + diff --git a/src/main/kotlin/gomushin/backend/core/oauth/handler/CustomSuccessHandler.kt b/src/main/kotlin/gomushin/backend/core/oauth/handler/CustomSuccessHandler.kt new file mode 100644 index 00000000..a920c36b --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/oauth/handler/CustomSuccessHandler.kt @@ -0,0 +1,62 @@ +package gomushin.backend.core.oauth.handler + +import gomushin.backend.core.configuration.cookie.CookieService +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.core.jwt.infrastructure.TokenService +import gomushin.backend.core.oauth.CustomOAuth2User +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.repository.MemberRepository +import gomushin.backend.member.domain.value.Role +import io.jsonwebtoken.io.IOException +import jakarta.servlet.ServletException +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.beans.factory.annotation.Value +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler +import org.springframework.stereotype.Component + +@Component +class CustomSuccessHandler( + private val tokenService: TokenService, + private val memberRepository: MemberRepository, + private val cookieService: CookieService, + @Value("\${member-redirect-url}") private val memberRedirectUrl: String, +) : SimpleUrlAuthenticationSuccessHandler() { + + @Throws(IOException::class, ServletException::class) + override fun onAuthenticationSuccess( + request: HttpServletRequest?, + response: HttpServletResponse?, + authentication: Authentication + ) { + val principal = authentication.principal + + if (principal !is CustomOAuth2User) { + throw BadRequestException("sarangggun.oauth.invalid-principal") + } + + var accessToken = "" + val refreshToken = tokenService.provideRefreshToken() + + getMemberByEmail(principal.getEmail())?.let { + accessToken = tokenService.provideAccessToken(it.id, it.role.name) + tokenService.upsertRefresh(it.id, refreshToken, tokenService.getTokenDuration(refreshToken)) + } ?: run { + accessToken = tokenService.provideAccessToken(principal.getUserId(), principal.getRole()) + tokenService.upsertRefresh(principal.getUserId(), refreshToken, tokenService.getTokenDuration(refreshToken)) + } + + val accessCookie = cookieService.createCookie("access_token", accessToken) + val refreshCookie = cookieService.createCookie("refresh_token", refreshToken) + + response!!.addHeader("Set-Cookie", accessCookie.toString()) + response.addHeader("Set-Cookie", refreshCookie.toString()) + response.sendRedirect(memberRedirectUrl) + } + + private fun getMemberByEmail(email: String): Member? { + return memberRepository.findByEmail(email) + } + +} diff --git a/src/main/kotlin/gomushin/backend/core/oauth/service/CustomOAuth2UserService.kt b/src/main/kotlin/gomushin/backend/core/oauth/service/CustomOAuth2UserService.kt new file mode 100644 index 00000000..cca01bae --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/oauth/service/CustomOAuth2UserService.kt @@ -0,0 +1,79 @@ +package gomushin.backend.core.oauth.service + +import gomushin.backend.core.oauth.dto.KakaoResponse +import gomushin.backend.core.oauth.dto.UserDTO +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.core.oauth.CustomOAuth2User +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.repository.MemberRepository +import gomushin.backend.member.domain.value.Provider +import gomushin.backend.member.domain.value.Role +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest +import org.springframework.security.oauth2.core.user.OAuth2User +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CustomOAuth2UserService( + private val memberRepository: MemberRepository +) : DefaultOAuth2UserService() { + + @Transactional + override fun loadUser(oauth2UserRequest: OAuth2UserRequest): OAuth2User? { + val oAuth2User = super.loadUser(oauth2UserRequest) + + val registrationId = oauth2UserRequest.clientRegistration.registrationId + val oAuth2Response = when (registrationId) { + "kakao" -> KakaoResponse(oAuth2User.attributes) + else -> throw BadRequestException("sarangggun.oauth.invalid-provider") + } + + val email = oAuth2Response.getEmail() + + getMemberByEmail(email)?.let { + it.email = oAuth2Response.getEmail() + it.name = oAuth2Response.getName() + it.profileImageUrl = oAuth2Response.getProfileImage() + + val savedMember = memberRepository.save(it) + + val userDto = UserDTO( + username = oAuth2Response.getProviderId(), + name = oAuth2Response.getName(), + email = oAuth2Response.getEmail(), + profileImage = oAuth2Response.getProfileImage(), + role = Role.MEMBER.name, + registrationId = registrationId, + userId = savedMember.id, + ) + + return CustomOAuth2User(userDto) + } ?: run { + val newMember = Member.create( + name = oAuth2Response.getName(), + nickname = oAuth2Response.getName(), + email = oAuth2Response.getEmail(), + profileImageUrl = oAuth2Response.getProfileImage(), + provider = Provider.getProviderByValue(registrationId), + ) + + val savedMember = memberRepository.save(newMember) + + val userDto = UserDTO( + username = oAuth2Response.getProviderId(), + name = oAuth2Response.getName(), + email = oAuth2Response.getEmail(), + profileImage = oAuth2Response.getProfileImage(), + role = Role.MEMBER.name, + registrationId = registrationId, + userId = savedMember.id, + ) + return CustomOAuth2User(userDto) + } + } + + private fun getMemberByEmail(email: String): Member? { + return memberRepository.findByEmail(email) + } +} diff --git a/src/main/kotlin/gomushin/backend/core/service/CustomUserDetailsService.kt b/src/main/kotlin/gomushin/backend/core/service/CustomUserDetailsService.kt new file mode 100644 index 00000000..fbb97fa7 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/service/CustomUserDetailsService.kt @@ -0,0 +1,26 @@ +package gomushin.backend.core.service + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.repository.CoupleRepository +import gomushin.backend.member.domain.repository.MemberRepository +import org.springframework.security.core.userdetails.UserDetails +import org.springframework.security.core.userdetails.UserDetailsService +import org.springframework.stereotype.Service + +@Service +class CustomUserDetailsService( + private val memberRepository: MemberRepository, + private val coupleRepository: CoupleRepository, +) : UserDetailsService { + override fun loadUserByUsername(email: String): UserDetails { + val member = memberRepository.findByEmail(email) + ?: throw BadRequestException("sarangggun.member.not-exist-member") + return CustomUserDetails(member, coupleRepository) + } + + fun loadUserById(id: Long): UserDetails = + memberRepository.findById(id) + .map { CustomUserDetails(it, coupleRepository) } + .orElseThrow { BadRequestException("sarangggun.member.not-exist-member") } +} diff --git a/src/main/kotlin/gomushin/backend/core/service/S3Service.kt b/src/main/kotlin/gomushin/backend/core/service/S3Service.kt new file mode 100644 index 00000000..81e87910 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/core/service/S3Service.kt @@ -0,0 +1,53 @@ +package gomushin.backend.core.service + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Propagation +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.util.* + +@Service +class S3Service( + private val s3Client: S3Client, + @Value("\${aws.s3.bucket}") private val bucket: String, + @Value("\${aws.s3.base-url}") private val baseUrl: String, +) { + + fun uploadFile(multipartFile: MultipartFile): String { + + val fileName = generateFileName(multipartFile) + + s3Client.putObject( + PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(multipartFile.contentType) + .build(), + RequestBody.fromInputStream(multipartFile.inputStream, multipartFile.size) + ) + + return getFileUrl(fileName) + } + + fun deleteFile(fileName: String) { + s3Client.deleteObject { it.bucket(bucket).key(getFileName(fileName)) } + } + + private fun getFileName(fileUrl: String): String { + return fileUrl.substringAfterLast("/") + } + + private fun getFileUrl(fileName: String): String { + val normalizedEndpoint = baseUrl.removeSuffix("/") + return "$normalizedEndpoint/$bucket/$fileName" + } + + private fun generateFileName(file: MultipartFile): String { + val safeName = file.originalFilename?.replace(" ", "_") ?: "unknown-file" + return "${UUID.randomUUID()}-$safeName" + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/entity/Anniversary.kt b/src/main/kotlin/gomushin/backend/couple/domain/entity/Anniversary.kt new file mode 100644 index 00000000..caa4607f --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/entity/Anniversary.kt @@ -0,0 +1,67 @@ +package gomushin.backend.couple.domain.entity + +import gomushin.backend.core.infrastructure.jpa.shared.BaseEntity +import gomushin.backend.couple.domain.value.AnniversaryEmoji +import jakarta.persistence.* +import java.time.LocalDate + +@Entity +@Table(name = "anniversary") +class Anniversary( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(name = "couple_id", nullable = false) + val coupleId: Long = 0L, + + @Column(name = "title", nullable = false) + var title: String, + + @Column(name = "anniversary_date", nullable = false) + var anniversaryDate: LocalDate, + + @Column(name = "anniversary_property", nullable = false) + var anniversaryProperty: Int, + + @Enumerated(EnumType.STRING) + @Column(name = "anniversary_emoji") + var emoji: AnniversaryEmoji? = null, + + @Column(name = "is_auto_insert") + var isAutoInsert: Boolean = false, +) : BaseEntity() { + companion object { + fun autoCreate(coupleId: Long, title: String, anniversaryDate: LocalDate): Anniversary { + return Anniversary( + coupleId = coupleId, + title = title, + anniversaryDate = anniversaryDate, + anniversaryProperty = 0, + emoji = AnniversaryEmoji.HEART, + isAutoInsert = true, + ) + } + + fun manualCreate( + coupleId: Long, + title: String, + anniversaryDate: LocalDate, + emoji: AnniversaryEmoji + ): Anniversary { + return Anniversary( + coupleId = coupleId, + title = title, + anniversaryDate = anniversaryDate, + anniversaryProperty = 1, + emoji = emoji + ) + } + } + + fun update(title: String?, anniversaryDate: LocalDate?, emoji: AnniversaryEmoji?) { + title?.let { this.title = it } + anniversaryDate?.let { this.anniversaryDate = it } + emoji?.let { this.emoji = it } + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/entity/Couple.kt b/src/main/kotlin/gomushin/backend/couple/domain/entity/Couple.kt new file mode 100644 index 00000000..92b59903 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/entity/Couple.kt @@ -0,0 +1,72 @@ +package gomushin.backend.couple.domain.entity + +import gomushin.backend.core.infrastructure.jpa.shared.BaseEntity +import gomushin.backend.couple.domain.value.Military +import jakarta.persistence.* +import java.time.LocalDate + +@Entity +@Table(name = "couple") +class Couple( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(name = "invitor_id", nullable = false) + val invitorId: Long = 0L, + + @Column(name = "invitee_id", nullable = false) + val inviteeId: Long = 0L, + + @Column(name = "relationship_start_date") + var relationshipStartDate: LocalDate? = null, + + @Column(name = "military_start_date") + var militaryStartDate: LocalDate? = null, + + @Column(name = "military_end_date") + var militaryEndDate: LocalDate? = null, + + @Column(name = "army") + @Enumerated(EnumType.STRING) + var military: Military? = null, + + @Column(name = "is_anniversaries_registered") + var isAnniversariesRegistered: Boolean = false, + + ) : BaseEntity() { + companion object { + fun of( + invitorId: Long, + inviteeId: Long, + ): Couple { + return Couple( + invitorId = invitorId, + inviteeId = inviteeId, + ) + } + } + + fun updateMilitary( + military: String + ) { + this.military = Military.getByName(military) + } + + fun updateAnniversary( + relationshipStartDate: LocalDate?, + militaryStartDate: LocalDate?, + militaryEndDate: LocalDate?, + ) { + this.relationshipStartDate = relationshipStartDate ?: this.relationshipStartDate + this.militaryStartDate = militaryStartDate ?: this.militaryStartDate + this.militaryEndDate = militaryEndDate ?: this.militaryEndDate + } + + fun containsUser(userId: Long): Boolean = + userId == invitorId || userId == inviteeId + + fun initAnniversaries() { + this.isAnniversariesRegistered = true + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/repository/AnniversaryRepository.kt b/src/main/kotlin/gomushin/backend/couple/domain/repository/AnniversaryRepository.kt new file mode 100644 index 00000000..9eeda508 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/repository/AnniversaryRepository.kt @@ -0,0 +1,126 @@ +package gomushin.backend.couple.domain.repository + +import gomushin.backend.couple.domain.entity.Anniversary +import gomushin.backend.couple.dto.response.AnniversaryNotificationInfo +import gomushin.backend.couple.dto.response.MonthlyAnniversariesResponse +import gomushin.backend.schedule.dto.response.DailyAnniversaryResponse +import gomushin.backend.schedule.dto.response.MainAnniversariesResponse +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDate + +interface AnniversaryRepository : JpaRepository { + @Modifying + @Query( + """ + DELETE FROM Anniversary a + WHERE (a.title LIKE '%일' OR a.title LIKE '%주년') + AND a.anniversaryProperty = 0 + And a.coupleId = :coupleId +""" + ) + fun deleteAnniversariesWithTitleEndingAndPropertyZero(@Param("coupleId") coupleId: Long) + + @Modifying + @Query("DELETE FROM Anniversary a WHERE a.coupleId = :coupleId") + fun deleteAllByCoupleId(@Param("coupleId") coupleId: Long) + + @Query( + """ + SELECT new gomushin.backend.couple.dto.response.MonthlyAnniversariesResponse( + a.title, + a.anniversaryDate + ) + FROM Anniversary a + WHERE a.coupleId = :coupleId + AND function('YEAR', a.anniversaryDate) = :year + AND function('MONTH', a.anniversaryDate) = :month + """ + ) + fun findByCoupleIdAndYearAndMonth(coupleId: Long, year: Int, month: Int): List + + @Query( + """ + SELECT new gomushin.backend.schedule.dto.response.DailyAnniversaryResponse( + a.id, + a.title, + a.emoji, + a.anniversaryDate + ) + FROM Anniversary a + WHERE a.coupleId = :coupleId + AND function('DATE', a.anniversaryDate) = :startDate + """ + ) + fun findByCoupleIdAndDate(coupleId: Long, startDate: LocalDate): List + + + @Query( + """ + SELECT new gomushin.backend.schedule.dto.response.MainAnniversariesResponse( + a.id, + a.title, + a.anniversaryDate + ) + FROM Anniversary a + WHERE a.coupleId = :coupleId + AND a.anniversaryDate BETWEEN :startDate AND :endDate + """ + ) + fun findByCoupleIdAndDateBetween( + coupleId: Long, + startDate: LocalDate, + endDate: LocalDate + ): List + + + @Query( + value = + """ + SELECT * FROM anniversary + WHERE couple_id = :coupleId + AND anniversary_date > CURRENT_DATE + ORDER BY anniversary_date ASC + LIMIT 3 + """, + nativeQuery = true + ) + fun findTop3UpcomingAnniversaries( + @Param("coupleId") coupleId: Long + ): List + + + @Query( + """ + SELECT a FROM Anniversary a + WHERE a.coupleId = :coupleId + ORDER BY a.anniversaryDate DESC + """ + ) + fun findAnniversaries( + @Param("coupleId") coupleId: Long, + pageable: Pageable + ): Page + + @Query( + """ + SELECT a.title AS title, m.id AS memberId, m.fcm_token AS fcmToken + FROM anniversary a + JOIN couple c ON a.couple_id = c.id + JOIN member m ON m.id = c.invitor_id OR m.id = c.invitee_id + JOIN notification n ON n.member_id = m.id + WHERE DATE(a.anniversary_date) = :nowDate + AND n.dday = true + """, + nativeQuery = true + ) + fun findTodayAnniversaryMemberFcmTokens(@Param("nowDate") date: LocalDate): List + + @Modifying + @Query("DELETE FROM Anniversary a WHERE a.coupleId = :coupleId AND a.isAutoInsert = true") + fun deleteAllByCoupleIdAndAutoInsertTrue(@Param("coupleId") coupleId: Long) +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/repository/CoupleRepository.kt b/src/main/kotlin/gomushin/backend/couple/domain/repository/CoupleRepository.kt new file mode 100644 index 00000000..a122535f --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/repository/CoupleRepository.kt @@ -0,0 +1,28 @@ +package gomushin.backend.couple.domain.repository + +import gomushin.backend.couple.domain.entity.Couple +import jakarta.persistence.LockModeType +import jakarta.persistence.QueryHint +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Lock +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.jpa.repository.QueryHints +import org.springframework.data.repository.query.Param + +interface CoupleRepository : JpaRepository { + fun findByInvitorId(invitorId: Long): Couple? + fun findByInviteeId(inviteeId: Long): Couple? + + @Query("SELECT c FROM Couple c WHERE c.invitorId = :memberId OR c.inviteeId = :memberId") + fun findByMemberId(@Param("memberId") memberId: Long): Couple? + + @Modifying + @Query("DELETE FROM Couple c WHERE c.invitorId = :memberId OR c.inviteeId = :memberId") + fun deleteByMemberId(@Param("memberId") memberId: Long) + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints(QueryHint(name = "javax.persistence.lock.timeout", value = "3000")) + @Query("SELECT c FROM Couple c WHERE c.id = :id") + fun findByIdWithLock(@Param("id") id: Long): Couple? +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/service/AnniversaryCalculator.kt b/src/main/kotlin/gomushin/backend/couple/domain/service/AnniversaryCalculator.kt new file mode 100644 index 00000000..e43df239 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/service/AnniversaryCalculator.kt @@ -0,0 +1,90 @@ +package gomushin.backend.couple.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.entity.Anniversary +import org.springframework.stereotype.Service +import java.time.LocalDate + +@Service +class AnniversaryCalculator { + fun calculateInitAnniversaries( + coupleId: Long, + relationShipStartDate: LocalDate, + militaryStartDate: LocalDate, + militaryEndDate: LocalDate, + anniversaryList: MutableList, + ): List { + + if (militaryStartDate.isAfter(militaryEndDate)) throw BadRequestException("sarangggun.military.invalid-date") + + calculateAnniversariesBetweenMilitaryStartDateAndMilitaryEndDate( + coupleId, + relationShipStartDate, + militaryStartDate, + militaryEndDate, + anniversaryList + ) + calculateHundredsAnniversariesBetweenMilitaryStartDateAndMilitaryEndDate( + coupleId, + relationShipStartDate, + militaryStartDate, + militaryEndDate, + anniversaryList + ) + return anniversaryList + } + + private fun calculateAnniversariesBetweenMilitaryStartDateAndMilitaryEndDate( + coupleId: Long, + relationShipStartDate: LocalDate, + militaryStartDate: LocalDate, + militaryEndDate: LocalDate, + anniversaryList: MutableList, + ) { + var anniversaryYear = 1L + + while (true) { + val anniversaryDate = relationShipStartDate.plusYears(anniversaryYear) + if (anniversaryDate.isAfter(militaryStartDate)) { + break + } else { + anniversaryYear++ + } + } + + while (true) { + val anniversaryDate = relationShipStartDate.plusYears(anniversaryYear) + if (anniversaryDate.isAfter(militaryEndDate)) { + break + } else { + val title = "${anniversaryYear}주년" + val anniversary = Anniversary.autoCreate(coupleId, title, anniversaryDate) + anniversaryList.add(anniversary) + anniversaryYear++ + } + } + } + + private fun calculateHundredsAnniversariesBetweenMilitaryStartDateAndMilitaryEndDate( + coupleId: Long, + relationShipStartDate: LocalDate, + militaryStartDate: LocalDate, + militaryEndDate: LocalDate, + anniversaryList: MutableList, + ) { + val plusDay = 100L + var anniversaryDay = 0L + var anniversaryDate = relationShipStartDate.minusDays(1) + while (true) { + anniversaryDate = anniversaryDate.plusDays(plusDay) + anniversaryDay += plusDay + if (anniversaryDate.isAfter(militaryEndDate)) { + break + } else if (anniversaryDate.isAfter(militaryStartDate) && anniversaryDate.isBefore(militaryEndDate)) { + val title = "${anniversaryDay}일" + val anniversary = Anniversary.autoCreate(coupleId, title, anniversaryDate) + anniversaryList.add(anniversary) + } + } + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/service/AnniversaryService.kt b/src/main/kotlin/gomushin/backend/couple/domain/service/AnniversaryService.kt new file mode 100644 index 00000000..4977611d --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/service/AnniversaryService.kt @@ -0,0 +1,119 @@ +package gomushin.backend.couple.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.entity.Anniversary +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.repository.AnniversaryRepository +import gomushin.backend.couple.domain.value.AnniversaryEmoji +import gomushin.backend.couple.dto.request.GenerateAnniversaryRequest +import gomushin.backend.couple.dto.request.UpdateAnniversaryRequest +import gomushin.backend.couple.dto.response.AnniversaryNotificationInfo +import gomushin.backend.couple.dto.response.MonthlyAnniversariesResponse +import gomushin.backend.schedule.dto.response.DailyAnniversaryResponse +import gomushin.backend.schedule.dto.response.MainAnniversariesResponse +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +class AnniversaryService( + private val anniversaryRepository: AnniversaryRepository, +) { + + @Transactional(readOnly = true) + fun getById(id: Long): Anniversary { + return findById(id) ?: throw BadRequestException("sarangggun.anniversary.not-found") + } + + @Transactional(readOnly = true) + fun findById(id: Long) = anniversaryRepository.findByIdOrNull(id) + + @Transactional(readOnly = true) + fun findAnniversaries(couple: Couple, pageRequest: PageRequest): Page { + return anniversaryRepository.findAnniversaries( + couple.id, + pageRequest + ) + } + + @Transactional(readOnly = true) + fun findByCoupleAndDateBetween( + couple: Couple, + startDate: LocalDate, + endDate: LocalDate + ): List { + return anniversaryRepository.findByCoupleIdAndDateBetween(couple.id, startDate, endDate) + } + + @Transactional(readOnly = true) + fun findByCoupleAndYearAndMonth(couple: Couple, year: Int, month: Int): List { + return anniversaryRepository.findByCoupleIdAndYearAndMonth(couple.id, year, month) + } + + @Transactional(readOnly = true) + fun findByCoupleAndDate(couple: Couple, date: LocalDate): List { + return anniversaryRepository.findByCoupleIdAndDate(couple.id, date) + } + + @Transactional + fun saveAll(anniversaries: List): List { + return anniversaryRepository.saveAll(anniversaries) + } + + @Transactional + fun deleteAllByCoupleId(coupleId: Long) { + return anniversaryRepository.deleteAllByCoupleId(coupleId) + } + + @Transactional + fun generateAnniversary(couple: Couple, generateAnniversaryRequest: GenerateAnniversaryRequest) { + anniversaryRepository.save( + Anniversary.manualCreate( + couple.id, + generateAnniversaryRequest.title, + generateAnniversaryRequest.date, + generateAnniversaryRequest.emoji + ) + ) + } + + @Transactional(readOnly = true) + fun getUpcomingTop3Anniversaries(couple: Couple): List { + return anniversaryRepository.findTop3UpcomingAnniversaries(couple.id) + } + + @Transactional(readOnly = true) + fun getTodayAnniversaryMemberFcmTokens(date: LocalDate): List { + return anniversaryRepository.findTodayAnniversaryMemberFcmTokens(date) + } + + @Transactional + fun delete(couple: Couple, anniversaryId: Long) { + val anniversary = getById(anniversaryId) + if (anniversary.coupleId != couple.id) { + throw BadRequestException("sarangggun.anniversary.unauthorized") + } + anniversaryRepository.deleteById(anniversaryId) + } + + @Transactional + fun deleteAllByCoupleAndAutoInsert(couple: Couple) { + return anniversaryRepository.deleteAllByCoupleIdAndAutoInsertTrue(couple.id) + } + + @Transactional + fun update(couple: Couple, anniversaryId: Long, updateAnniversaryRequest: UpdateAnniversaryRequest) { + val anniversary = getById(anniversaryId) + if (anniversary.coupleId != couple.id) { + throw BadRequestException("sarangggun.anniversary.unauthorized") + } + anniversary.update( + title = updateAnniversaryRequest.title, + anniversaryDate = updateAnniversaryRequest.anniversaryDate, + emoji = AnniversaryEmoji.getByName(updateAnniversaryRequest.emoji) + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleConnectService.kt b/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleConnectService.kt new file mode 100644 index 00000000..f22e6b3f --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleConnectService.kt @@ -0,0 +1,88 @@ +package gomushin.backend.couple.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.core.infrastructure.filter.logging.LoggingFilter +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.member.util.CoupleCodeGeneratorUtil +import org.slf4j.LoggerFactory +import org.springframework.data.redis.core.StringRedisTemplate +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.Duration + +@Service +class CoupleConnectService( + private val redisTemplate: StringRedisTemplate, + private val coupleService: CoupleService, + private val memberService: MemberService, +) { + private val log = LoggerFactory.getLogger(CoupleConnectService::class.java) + companion object { + private val COUPLE_CODE_DURATION = Duration.ofMinutes(60) + private const val COUPLE_CODE_PREFIX = "COUPLE_CODE:" + } + + fun generateCoupleCode(userId: Long): String { + val coupleCode = CoupleCodeGeneratorUtil.generateCoupleCode() + val key = getCoupleCodeKey(coupleCode) + redisTemplate.opsForValue().set(key, userId.toString(), COUPLE_CODE_DURATION) + log.info("[GenerateCoupleCode] generator_userId : {}, code : {}", userId, coupleCode) + return coupleCode + } + + @Transactional + fun connectCouple(inviteeId: Long, coupleCode: String): Couple { + val key = getCoupleCodeKey(coupleCode) + val invitorId = getCoupleCodeOrNull(key) + ?: throw BadRequestException("sarangggun.couple.invalid-couple-code") + if (invitorId == inviteeId) { + throw BadRequestException("sarangggun.couple.couple-code-same") + } + log.info("[ConnectCouple] invitorId : {}, inviteeId : {}", invitorId, inviteeId) + val couple = Couple.of( + invitorId, + inviteeId, + ) + + if (isAlreadyConnected(inviteeId) || isAlreadyConnected(invitorId)) { + throw BadRequestException("sarangggun.couple.already-connected") + } + + val savedCouple = save(couple) + delete(key) + log.info("[ConnectCouple] invitorId : {}, inviteeId : {} - connect Succeed!", invitorId, inviteeId) + return savedCouple + } + + @Transactional + fun save(couple: Couple): Couple { + updateCoupleStatus(couple.invitorId) + updateCoupleStatus(couple.inviteeId) + return coupleService.save(couple) + } + + @Transactional + fun updateCoupleStatus(userId: Long) { + val member = memberService.getById(userId) + member.updateCoupleStatus() + } + + + private fun getCoupleCodeOrNull(key: String): Long? { + return redisTemplate.opsForValue().get(key)?.toLongOrNull() + } + + private fun delete(key: String) { + redisTemplate.delete(key) + } + + private fun getCoupleCodeKey(code: String): String { + return "$COUPLE_CODE_PREFIX$code" + } + + private fun isAlreadyConnected(userId: Long): Boolean { + val member = memberService.getById(userId) + return member.isCouple + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleInfoService.kt b/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleInfoService.kt new file mode 100644 index 00000000..9711a8ca --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleInfoService.kt @@ -0,0 +1,141 @@ +package gomushin.backend.couple.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.entity.Anniversary +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.repository.AnniversaryRepository +import gomushin.backend.couple.domain.repository.CoupleRepository +import gomushin.backend.couple.dto.request.UpdateMilitaryDateRequest +import gomushin.backend.couple.dto.request.UpdateRelationshipStartDateRequest +import gomushin.backend.couple.dto.response.DdayResponse +import gomushin.backend.couple.dto.response.NicknameResponse +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.repository.MemberRepository +import gomushin.backend.member.domain.value.Emotion +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate +import java.time.Period +import java.time.temporal.ChronoUnit + +@Service +class CoupleInfoService( + private val coupleRepository: CoupleRepository, + private val memberRepository: MemberRepository, + private val anniversaryRepository: AnniversaryRepository, + private val anniversaryCalculator: AnniversaryCalculator +) { + @Transactional(readOnly = true) + fun getGrade(id: Long): Int { + val couple = coupleRepository.findByMemberId(id) + ?: throw BadRequestException("saranggun.couple.not-connected") + val militaryStartDate = couple.militaryStartDate ?: throw BadRequestException("saranggun.couple.not-defined-militaryStartDate") + val today = LocalDate.now() + return computeGrade(militaryStartDate, today) + } + + fun computeGrade(militaryStartDate: LocalDate, today: LocalDate) : Int { + val period = Period.between(militaryStartDate, today) + val totalMonths = period.years * 12 + period.months + if (period.days >= 1) 1 else 0 + + return when { + totalMonths < 2 -> 1 + totalMonths < 8 -> 2 + totalMonths < 14 -> 3 + else -> 4 + } + } + + @Transactional(readOnly = true) + fun checkCouple(id: Long): Boolean { + return memberRepository.findById(id) + .orElseThrow{ BadRequestException("sarangggun.member.not-exist-member") } + .isCouple + } + + @Transactional(readOnly = true) + fun getDday(id: Long): DdayResponse { + val couple = coupleRepository.findByMemberId(id) + ?: throw BadRequestException("saranggun.couple.not-connected") + val today = LocalDate.now() + val sinceLove: Int? = couple.relationshipStartDate?.let { startLove -> + computeDday(startLove, today) + 1 + } + val sinceMilitaryStart : Int? = couple.militaryStartDate?.let { startMilitary -> + computeDday(startMilitary, today) + } + val militaryEndLeft : Int? = couple.militaryEndDate?.let { endMilitary -> + computeDday(endMilitary, today) + } + return DdayResponse.of(sinceLove, sinceMilitaryStart, militaryEndLeft) + } + + fun computeDday(day1: LocalDate, day2: LocalDate) : Int { + return ChronoUnit.DAYS.between(day1, day2).toInt() + } + + @Transactional(readOnly = true) + fun getNickName(id: Long): NicknameResponse { + val userMember = memberRepository.findById(id).orElseThrow { + BadRequestException("saranggun.member.not-found") + } + + val coupleMember = findCoupleMember(id) + + return NicknameResponse.of(userMember.nickname, coupleMember.nickname) + } + + @Transactional(readOnly = true) + fun getStatusMessage(id: Long): String? { + val coupleMember = findCoupleMember(id) + return coupleMember.statusMessage + } + + @Transactional(readOnly = true) + fun findCoupleMember(id : Long): Member { + val couple = coupleRepository.findByMemberId(id) ?: throw BadRequestException("saranggun.couple.not-connected") + val coupleMemberId = if (couple.invitorId == id) couple.inviteeId else couple.invitorId + + val coupleMember = memberRepository.findById(coupleMemberId).orElseThrow { + BadRequestException("sarangggun.couple.not-exist-couple") + } + return coupleMember + } + + @Transactional + fun updateMilitaryDate(couple: Couple, updateMilitaryDateRequest: UpdateMilitaryDateRequest) { + updateAnniversary(couple, couple.relationshipStartDate!!, updateMilitaryDateRequest.militaryStartDate, updateMilitaryDateRequest.militaryEndDate) + couple.updateAnniversary(couple.relationshipStartDate!!, + updateMilitaryDateRequest.militaryStartDate, + updateMilitaryDateRequest.militaryEndDate) + } + @Transactional + fun updateRelationshipStartDate(couple: Couple, updateRelationshipStartDateRequest: UpdateRelationshipStartDateRequest) { + updateAnniversary(couple, updateRelationshipStartDateRequest.relationshipStartDate, couple.militaryStartDate!!, couple.militaryEndDate!!) + couple.updateAnniversary(updateRelationshipStartDateRequest.relationshipStartDate, + couple.militaryStartDate, + couple.militaryEndDate) + } + + private fun updateAnniversary(couple: Couple, + relationshipStartDate: LocalDate, + militaryStartDate: LocalDate, + militaryEndDate : LocalDate) { + anniversaryRepository.deleteAnniversariesWithTitleEndingAndPropertyZero(couple.id) + val anniversaries: MutableList = mutableListOf() + anniversaryCalculator.calculateInitAnniversaries( + couple.id, + relationshipStartDate, + militaryStartDate, + militaryEndDate, + anniversaries + ) + anniversaryRepository.saveAll(anniversaries) + } + + @Transactional(readOnly = true) + fun getCoupleEmotion(id: Long): Emotion { + val coupleMember = findCoupleMember(id) + return coupleMember.emotion ?: throw BadRequestException("sarangggun.member.not-exist-emoji") + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleService.kt b/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleService.kt new file mode 100644 index 00000000..db63ac86 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/service/CoupleService.kt @@ -0,0 +1,53 @@ +package gomushin.backend.couple.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.repository.CoupleRepository +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CoupleService( + private val coupleRepository: CoupleRepository +) { + @Transactional(readOnly = true) + fun getById(id: Long): Couple { + return findById(id) ?: throw BadRequestException("sarangggun.couple.not-found") + } + + @Transactional(readOnly = true) + fun getByIdWithLock(id: Long): Couple { + return findByIdWithLock(id) ?: throw BadRequestException("sarangggun.couple.not-found") + } + + @Transactional(readOnly = true) + fun findByIdWithLock(id: Long): Couple? { + return coupleRepository.findByIdWithLock(id) + } + + @Transactional(readOnly = true) + fun findById(id: Long): Couple? { + return coupleRepository.findByIdOrNull(id) + } + + @Transactional + fun save(couple: Couple): Couple { + return coupleRepository.save(couple) + } + + @Transactional(readOnly = true) + fun getByMemberId(memberId: Long): Couple { + return findByMemberId(memberId) ?: throw BadRequestException("sarangggun.couple.not-found") + } + + @Transactional(readOnly = true) + fun findByMemberId(memberId: Long): Couple? { + return coupleRepository.findByMemberId(memberId) + } + + @Transactional + fun deleteByMemberId(memberId: Long) { + coupleRepository.deleteByMemberId(memberId) + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/value/AnniversaryEmoji.kt b/src/main/kotlin/gomushin/backend/couple/domain/value/AnniversaryEmoji.kt new file mode 100644 index 00000000..b1f64655 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/value/AnniversaryEmoji.kt @@ -0,0 +1,11 @@ +package gomushin.backend.couple.domain.value + +enum class AnniversaryEmoji { + HEART, CALENDAR, CAKE, TRAVEL; + + companion object { + fun getByName(name: String?): AnniversaryEmoji? { + return entries.find { it.name.equals(name, ignoreCase = true) } + } + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/domain/value/Military.kt b/src/main/kotlin/gomushin/backend/couple/domain/value/Military.kt new file mode 100644 index 00000000..56312b77 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/domain/value/Military.kt @@ -0,0 +1,17 @@ +package gomushin.backend.couple.domain.value + +import gomushin.backend.core.infrastructure.exception.BadRequestException + +enum class Military { + ARMY, + NAVY, + AIR_FORCE, + MARINE; + + companion object { + fun getByName(name: String): Military { + return entries.firstOrNull { it.name == name } + ?: throw BadRequestException("sarangggun.military.not-exist-military") + } + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/dto/request/CoupleAnniversaryRequest.kt b/src/main/kotlin/gomushin/backend/couple/dto/request/CoupleAnniversaryRequest.kt new file mode 100644 index 00000000..cb553a35 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/request/CoupleAnniversaryRequest.kt @@ -0,0 +1,21 @@ +package gomushin.backend.couple.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class CoupleAnniversaryRequest( + @Schema(description = "커플 ID", example = "1") + val coupleId: Long, + + @Schema(description = "처음 만난 날", example = "2022-10-01") + val relationshipStartDate: LocalDate, + + @Schema(description = "입대일", example = "2023-01-01") + val militaryStartDate: LocalDate, + + @Schema(description = "전역일", example = "2024-10-28") + val militaryEndDate: LocalDate, + + @Schema(description = "부대", example = "ARMY | NAVY | AIR_FORCE | MARINE 중 택 1 ") + val military: String, +) diff --git a/src/main/kotlin/gomushin/backend/couple/dto/request/CoupleConnectRequest.kt b/src/main/kotlin/gomushin/backend/couple/dto/request/CoupleConnectRequest.kt new file mode 100644 index 00000000..92e1cb44 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/request/CoupleConnectRequest.kt @@ -0,0 +1,5 @@ +package gomushin.backend.couple.dto.request + +data class CoupleConnectRequest( + val coupleCode: String +) diff --git a/src/main/kotlin/gomushin/backend/couple/dto/request/GenerateAnniversaryRequest.kt b/src/main/kotlin/gomushin/backend/couple/dto/request/GenerateAnniversaryRequest.kt new file mode 100644 index 00000000..ccf8ff88 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/request/GenerateAnniversaryRequest.kt @@ -0,0 +1,14 @@ +package gomushin.backend.couple.dto.request + +import gomushin.backend.couple.domain.value.AnniversaryEmoji +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class GenerateAnniversaryRequest( + @Schema(description = "제목", example = "전역일") + val title : String, + @Schema(description = "이모지", example = "HEART, CALENDAR, CAKE, TRAVEL") + val emoji : AnniversaryEmoji, + @Schema(description = "날짜", example = "2025-05-01") + val date : LocalDate, +) diff --git a/src/main/kotlin/gomushin/backend/couple/dto/request/ReadAnniversariesRequest.kt b/src/main/kotlin/gomushin/backend/couple/dto/request/ReadAnniversariesRequest.kt new file mode 100644 index 00000000..99e2b07f --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/request/ReadAnniversariesRequest.kt @@ -0,0 +1,7 @@ +package gomushin.backend.couple.dto.request + +data class ReadAnniversariesRequest( + val key: Long = Long.MAX_VALUE, + val orderCreatedAt: String = "DESC", + val take: Long = 10L, +) diff --git a/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateAnniversaryRequest.kt b/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateAnniversaryRequest.kt new file mode 100644 index 00000000..c582fed1 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateAnniversaryRequest.kt @@ -0,0 +1,9 @@ +package gomushin.backend.couple.dto.request + +import java.time.LocalDate + +data class UpdateAnniversaryRequest( + val title: String? = null, + val emoji: String? = null, + val anniversaryDate: LocalDate? = null +) diff --git a/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateMilitaryDateRequest.kt b/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateMilitaryDateRequest.kt new file mode 100644 index 00000000..1e6faf29 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateMilitaryDateRequest.kt @@ -0,0 +1,11 @@ +package gomushin.backend.couple.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class UpdateMilitaryDateRequest ( + @Schema(description = "입대일", example = "2023-05-24") + val militaryStartDate : LocalDate, + @Schema(description = "전역일", example = "2024-11-23") + val militaryEndDate : LocalDate +) \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateRelationshipStartDateRequest.kt b/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateRelationshipStartDateRequest.kt new file mode 100644 index 00000000..4cfb48cd --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/request/UpdateRelationshipStartDateRequest.kt @@ -0,0 +1,9 @@ +package gomushin.backend.couple.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class UpdateRelationshipStartDateRequest ( + @Schema(description = "사귄날", example = "2023-05-24") + val relationshipStartDate : LocalDate, +) \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/AnniversaryDetailResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/AnniversaryDetailResponse.kt new file mode 100644 index 00000000..817d64e8 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/AnniversaryDetailResponse.kt @@ -0,0 +1,20 @@ +package gomushin.backend.couple.dto.response + +import gomushin.backend.couple.domain.entity.Anniversary +import java.time.LocalDate + +data class AnniversaryDetailResponse( + val id: Long, + val title: String, + val anniversaryDate: LocalDate, +) { + companion object { + fun of(anniversary: Anniversary): AnniversaryDetailResponse { + return AnniversaryDetailResponse( + id = anniversary.id, + title = anniversary.title, + anniversaryDate = anniversary.anniversaryDate, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/AnniversaryNotificationInfo.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/AnniversaryNotificationInfo.kt new file mode 100644 index 00000000..50df8c71 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/AnniversaryNotificationInfo.kt @@ -0,0 +1,7 @@ +package gomushin.backend.couple.dto.response + +interface AnniversaryNotificationInfo { + val title: String + val memberId: Long + val fcmToken: String +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleBirthDayResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleBirthDayResponse.kt new file mode 100644 index 00000000..59a74c5b --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleBirthDayResponse.kt @@ -0,0 +1,19 @@ +package gomushin.backend.couple.dto.response + +import gomushin.backend.member.domain.entity.Member +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class CoupleBirthDayResponse ( + @Schema(description = "커플 생년월일", example = "2001-04-01") + val partnerBirthday : LocalDate?, + @Schema(description = "내 생년월일", example = "2001-03-01") + val myBirthDay : LocalDate? +) { + companion object { + fun of(coupleMember : Member, myMember : Member) = CoupleBirthDayResponse( + coupleMember.birthDate, + myMember.birthDate + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleEmotionResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleEmotionResponse.kt new file mode 100644 index 00000000..3442896a --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleEmotionResponse.kt @@ -0,0 +1,13 @@ +package gomushin.backend.couple.dto.response + +import gomushin.backend.member.domain.value.Emotion + +data class CoupleEmotionResponse ( + val emotion : String +) { + companion object{ + fun of(emotion: Emotion) = CoupleEmotionResponse( + emotion.name + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleGradeResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleGradeResponse.kt new file mode 100644 index 00000000..a11b34fe --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleGradeResponse.kt @@ -0,0 +1,10 @@ +package gomushin.backend.couple.dto.response +data class CoupleGradeResponse( + val grade : Int +){ + companion object{ + fun of(grade: Int) = CoupleGradeResponse( + grade + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleInfoResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleInfoResponse.kt new file mode 100644 index 00000000..e0e232a1 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/CoupleInfoResponse.kt @@ -0,0 +1,25 @@ +package gomushin.backend.couple.dto.response + +import gomushin.backend.couple.domain.entity.Couple +import io.swagger.v3.oas.annotations.media.Schema + +data class CoupleInfoResponse( + @Schema(description = "커플 ID", example = "1") + val coupleId: Long, + + @Schema(description = "속해있는 군", example = "MARINE") + val military: String, + + @Schema(description = "커플 기념일 초기화 되었는지 여부", example = "false") + val isAnniversariesRegistered: Boolean = false, +) { + companion object { + fun of(couple: Couple): CoupleInfoResponse { + return CoupleInfoResponse( + coupleId = couple.id, + military = couple.military.toString(), + isAnniversariesRegistered = couple.isAnniversariesRegistered, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/DdayResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/DdayResponse.kt new file mode 100644 index 00000000..d599bce4 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/DdayResponse.kt @@ -0,0 +1,24 @@ +package gomushin.backend.couple.dto.response + +data class DdayResponse ( + val sinceLove : String?, + val sinceMilitaryStart : String?, + val militaryEndLeft : String? +){ + companion object{ + fun of( + sinceLove: Int?, + sinceMilitaryStart: Int?, + militaryEndLeft: Int? + ): DdayResponse { + fun format(day: Int?): String? = + day?.let { if (it > 0) "+$it" else if (it == 0) "-DAY" else it.toString() } + + return DdayResponse( + sinceLove = format(sinceLove), + sinceMilitaryStart = format(sinceMilitaryStart), + militaryEndLeft = format(militaryEndLeft) + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/MainAnniversaryResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/MainAnniversaryResponse.kt new file mode 100644 index 00000000..7df035dd --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/MainAnniversaryResponse.kt @@ -0,0 +1,24 @@ +package gomushin.backend.couple.dto.response + +import gomushin.backend.couple.domain.entity.Anniversary +import java.time.LocalDate + +data class MainAnniversaryResponse( + val id: Long, + val emoji: String, + val title: String, + val anniversaryDate: LocalDate, +) { + companion object { + fun of( + anniversary: Anniversary + ): MainAnniversaryResponse { + return MainAnniversaryResponse( + id = anniversary.id, + emoji = anniversary.emoji!!.name, + title = anniversary.title, + anniversaryDate = anniversary.anniversaryDate + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/MonthlyAnniversariesResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/MonthlyAnniversariesResponse.kt new file mode 100644 index 00000000..d2d59f55 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/MonthlyAnniversariesResponse.kt @@ -0,0 +1,8 @@ +package gomushin.backend.couple.dto.response + +import java.time.LocalDate + +data class MonthlyAnniversariesResponse( + val title: String, + val anniversaryDate: LocalDate +) diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/NicknameResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/NicknameResponse.kt new file mode 100644 index 00000000..de33266e --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/NicknameResponse.kt @@ -0,0 +1,16 @@ +package gomushin.backend.couple.dto.response + +data class NicknameResponse ( + val userNickname : String, + val coupleNickname : String +) { + companion object { + fun of( + userNickname: String, + coupleNickname: String + ) = NicknameResponse( + userNickname, + coupleNickname + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/StatusMessageResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/StatusMessageResponse.kt new file mode 100644 index 00000000..c80d4359 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/StatusMessageResponse.kt @@ -0,0 +1,13 @@ +package gomushin.backend.couple.dto.response + +data class StatusMessageResponse ( + val statusMessage : String? +) { + companion object { + fun of( + statusMessage: String? + )=StatusMessageResponse( + statusMessage + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/dto/response/TotalAnniversaryResponse.kt b/src/main/kotlin/gomushin/backend/couple/dto/response/TotalAnniversaryResponse.kt new file mode 100644 index 00000000..cf161143 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/dto/response/TotalAnniversaryResponse.kt @@ -0,0 +1,24 @@ +package gomushin.backend.couple.dto.response + +import gomushin.backend.couple.domain.entity.Anniversary +import java.time.LocalDate + +data class TotalAnniversaryResponse( + val id: Long?, + val title: String?, + val anniversaryDate: LocalDate?, + val emoji: String?, +) { + companion object { + fun of( + anniversary: Anniversary? + ): TotalAnniversaryResponse { + return TotalAnniversaryResponse( + id = anniversary?.id, + title = anniversary?.title, + anniversaryDate = anniversary?.anniversaryDate, + emoji = anniversary?.emoji.toString() + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/facade/AnniversaryFacade.kt b/src/main/kotlin/gomushin/backend/couple/facade/AnniversaryFacade.kt new file mode 100644 index 00000000..d3224413 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/facade/AnniversaryFacade.kt @@ -0,0 +1,57 @@ +package gomushin.backend.couple.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.PageResponse +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.service.AnniversaryService +import gomushin.backend.couple.dto.request.UpdateAnniversaryRequest +import gomushin.backend.couple.dto.response.AnniversaryDetailResponse +import gomushin.backend.couple.dto.response.MainAnniversaryResponse +import gomushin.backend.couple.dto.response.TotalAnniversaryResponse +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Component + +@Component +class AnniversaryFacade( + private val anniversaryService: AnniversaryService, +) { + fun getAnniversaryListMain(customUserDetails: CustomUserDetails): List { + val anniversaries = anniversaryService.getUpcomingTop3Anniversaries(customUserDetails.getCouple()) + return anniversaries.map { MainAnniversaryResponse.of(it) } + } + + fun getAnniversaryList( + customUserDetails: CustomUserDetails, + page: Int, + size: Int, + ): PageResponse { + val pageRequest = PageRequest.of(page, size) + val anniversaries = + anniversaryService.findAnniversaries(customUserDetails.getCouple(), pageRequest) + val anniversaryResponses = anniversaries.map { anniversary -> + TotalAnniversaryResponse.of(anniversary) + } + + return PageResponse.from(anniversaryResponses) + } + + fun get(customUserDetails: CustomUserDetails, anniversaryId: Long): AnniversaryDetailResponse { + val anniversary = anniversaryService.getById(anniversaryId) + if (anniversary.coupleId != customUserDetails.getCouple().id) { + throw BadRequestException("sarangggun.anniversary.unauthorized") + } + return AnniversaryDetailResponse.of(anniversary) + } + + fun delete(customUserDetails: CustomUserDetails, anniversaryId: Long) { + anniversaryService.delete(customUserDetails.getCouple(), anniversaryId) + } + + fun updateAnniversary( + customUserDetails: CustomUserDetails, + anniversaryId: Long, + updateAnniversaryRequest: UpdateAnniversaryRequest, + ) { + anniversaryService.update(customUserDetails.getCouple(), anniversaryId, updateAnniversaryRequest) + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/facade/CoupleFacade.kt b/src/main/kotlin/gomushin/backend/couple/facade/CoupleFacade.kt new file mode 100644 index 00000000..204b2428 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/facade/CoupleFacade.kt @@ -0,0 +1,141 @@ +package gomushin.backend.couple.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.entity.Anniversary +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.service.* +import gomushin.backend.couple.dto.request.* +import gomushin.backend.couple.dto.response.* +import gomushin.backend.member.domain.service.MemberService +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class CoupleFacade( + private val coupleConnectService: CoupleConnectService, + private val anniversaryService: AnniversaryService, + private val coupleInfoService: CoupleInfoService, + private val coupleService: CoupleService, + private val memberService: MemberService, + private val anniversaryCalculator: AnniversaryCalculator +) { + + fun getInfo(customUserDetails: CustomUserDetails): CoupleInfoResponse { + val member = memberService.getById(customUserDetails.getId()) + + if (!member.checkIsCouple()) { + throw BadRequestException("saranggun.couple.not-connected") + } + + val couple = coupleService.getById(customUserDetails.getCouple().id) + return CoupleInfoResponse.of(couple) + } + + fun requestCoupleCodeGeneration(customUserDetails: CustomUserDetails) = + coupleConnectService.generateCoupleCode(customUserDetails.getId()) + + fun requestCoupleConnect( + customUserDetails: CustomUserDetails, + request: CoupleConnectRequest + ) = coupleConnectService.connectCouple(customUserDetails.getId(), request.coupleCode) + + @Transactional + fun registerAnniversary( + customUserDetails: CustomUserDetails, + request: CoupleAnniversaryRequest + ) { + val couple = coupleService.getByIdWithLock(request.coupleId) + checkUserInCouple(customUserDetails.getId(), couple) + checkCoupleAnniversaryIsInit(couple) + + couple.updateMilitary(request.military) + + couple.updateAnniversary( + relationshipStartDate = request.relationshipStartDate, + militaryStartDate = request.militaryStartDate, + militaryEndDate = request.militaryEndDate, + ) + + val anniversaries: MutableList = mutableListOf() + + anniversaryCalculator.calculateInitAnniversaries( + couple.id, + request.relationshipStartDate, + request.militaryStartDate, + request.militaryEndDate, + anniversaries + ) + + couple.initAnniversaries() + + anniversaryService.saveAll(anniversaries) + } + + fun getGradeInfo(customUserDetails: CustomUserDetails): CoupleGradeResponse { + val grade = coupleInfoService.getGrade(customUserDetails.getId()) + return CoupleGradeResponse.of(grade) + } + + fun checkConnect(customUserDetails: CustomUserDetails): Boolean { + return coupleInfoService.checkCouple(customUserDetails.getId()) + } + + fun getDday(customUserDetails: CustomUserDetails): DdayResponse { + return coupleInfoService.getDday(customUserDetails.getId()) + } + + fun nickName(customUserDetails: CustomUserDetails): NicknameResponse { + return coupleInfoService.getNickName(customUserDetails.getId()) + } + + fun statusMessage(customUserDetails: CustomUserDetails): StatusMessageResponse { + val statusMessage = coupleInfoService.getStatusMessage(customUserDetails.getId()) + return StatusMessageResponse.of(statusMessage) + } + + @Transactional + fun updateMilitaryDate(customUserDetails: CustomUserDetails, updateMilitaryDateRequest: UpdateMilitaryDateRequest) { + val couple = coupleService.getByMemberId(customUserDetails.getId()) + anniversaryService.deleteAllByCoupleAndAutoInsert(couple) + coupleInfoService.updateMilitaryDate(couple, updateMilitaryDateRequest) + } + + fun updateRelationshipStartDate( + customUserDetails: CustomUserDetails, + updateRelationshipStartDateRequest: UpdateRelationshipStartDateRequest + ) { + val couple = coupleService.getByMemberId(customUserDetails.getId()) + coupleInfoService.updateRelationshipStartDate(couple, updateRelationshipStartDateRequest) + } + + fun getCoupleEmotion(customUserDetails: CustomUserDetails): CoupleEmotionResponse { + val emotion = coupleInfoService.getCoupleEmotion(customUserDetails.getId()) + return CoupleEmotionResponse.of(emotion) + } + + fun generateAnniversary( + customUserDetails: CustomUserDetails, + generateAnniversaryRequest: GenerateAnniversaryRequest + ) { + anniversaryService.generateAnniversary(customUserDetails.getCouple(), generateAnniversaryRequest) + } + + private fun checkUserInCouple(userId: Long, couple: Couple) { + if (!couple.containsUser(userId)) { + throw BadRequestException("sarangggun.couple.not-in-couple") + } + } + + private fun checkCoupleAnniversaryIsInit(couple: Couple) { + if (couple.isAnniversariesRegistered) { + throw BadRequestException("sarangggun.couple.already-init") + } + } + + fun getCoupleBirthDay(customUserDetails: CustomUserDetails): CoupleBirthDayResponse { + val couple = coupleInfoService.findCoupleMember(customUserDetails.getId()) + val member = memberService.getById(customUserDetails.getId()) + return CoupleBirthDayResponse.of(couple, member) + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/presentation/AnniversaryController.kt b/src/main/kotlin/gomushin/backend/couple/presentation/AnniversaryController.kt new file mode 100644 index 00000000..aa12f75c --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/presentation/AnniversaryController.kt @@ -0,0 +1,110 @@ +package gomushin.backend.couple.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.PageResponse +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.couple.dto.request.GenerateAnniversaryRequest +import gomushin.backend.couple.dto.response.AnniversaryDetailResponse +import gomushin.backend.couple.dto.request.UpdateAnniversaryRequest +import gomushin.backend.couple.dto.response.MainAnniversaryResponse +import gomushin.backend.couple.dto.response.TotalAnniversaryResponse +import gomushin.backend.couple.facade.AnniversaryFacade +import gomushin.backend.couple.facade.CoupleFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.* + +@RestController +@Tag(name = "기념일", description = "AnniversaryController") +class AnniversaryController( + private val coupleFacade: CoupleFacade, + private val anniversaryFacade: AnniversaryFacade +) { + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(ApiPath.ANNIVERSARY_GENERATE) + @Operation( + summary = "기념일 생성", + description = "generateAnniversary" + ) + fun generateAnniversary( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody generateAnniversaryRequest: GenerateAnniversaryRequest + ): ApiResponse { + coupleFacade.generateAnniversary(customUserDetails, generateAnniversaryRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @PutMapping(ApiPath.ANNIVERSARY) + @Operation( + summary = "기념일 수정", + description = "updateAnniversary" + ) + fun updateAnniversary( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable anniversaryId: Long, + @RequestBody updateAnniversaryRequest: UpdateAnniversaryRequest, + ): ApiResponse { + anniversaryFacade.updateAnniversary(customUserDetails, anniversaryId, updateAnniversaryRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping(ApiPath.ANNIVERSARY) + @Operation( + summary = "기념일 삭제", + description = "deleteAnniversary" + ) + fun deleteAnniversary( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable anniversaryId: Long + ): ApiResponse { + anniversaryFacade.delete(customUserDetails, anniversaryId) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.ANNIVERSARY_MAIN) + @Operation( + summary = "메인 - 가까운 3개의 기념일 조회", + description = "getAnniversariesMain" + ) + fun getAnniversariesMain( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ): ApiResponse> = + ApiResponse.success(anniversaryFacade.getAnniversaryListMain(customUserDetails)) + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.ANNIVERSARIES) + @Operation( + summary = "기념일 리스트 조회", + description = "getAnniversaries" + ) + fun getAnniversaryList( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): PageResponse { + val safePage = if (page < 1) 0 else page - 1 + val anniversaries = anniversaryFacade.getAnniversaryList(customUserDetails, safePage, size) + return anniversaries + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.ANNIVERSARY) + @Operation( + summary = "기념일 상세 조회", + description = "getAnniversary" + ) + fun getAnniversary( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable anniversaryId: Long + ): ApiResponse { + val anniversary = anniversaryFacade.get(customUserDetails, anniversaryId) + return ApiResponse.success(anniversary) + } + + +} diff --git a/src/main/kotlin/gomushin/backend/couple/presentation/ApiPath.kt b/src/main/kotlin/gomushin/backend/couple/presentation/ApiPath.kt new file mode 100644 index 00000000..8eaf5569 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/presentation/ApiPath.kt @@ -0,0 +1,21 @@ +package gomushin.backend.couple.presentation + +object ApiPath { + const val COUPLE = "/v1/couple" + const val COUPLE_CODE_GENERATE = "/v1/couple/code-generate" + const val COUPLE_CONNECT = "/v1/couple/connect" + const val COUPLE_ANNIVERSARY = "/v1/couple/anniversary" + const val COUPLE_PROFILE = "/v1/couple/profile" + const val COUPLE_CHECK_CONNECT = "/v1/couple/check-connect" + const val COUPLE_DDAY_INFO = "/v1/couple/d-day" + const val COUPLE_NICKNAME = "/v1/couple/nick-name" + const val COUPLE_STATUS_MESSAGE = "/v1/couple/status-message" + const val COUPLE_UPDATE_MILITARY_DATE = "/v1/couple/military-date" + const val COUPLE_UPDATE_RELATIONSHIP_DATE = "/v1/couple/relationship-start-date" + const val COUPLE_EMOJI = "/v1/couple/emotion" + const val ANNIVERSARY_GENERATE = "/v1/couple/new-anniversary" + const val ANNIVERSARY_MAIN = "/v1/anniversary/main" + const val ANNIVERSARIES = "/v1/anniversaries" + const val ANNIVERSARY = "/v1/anniversary/{anniversaryId}" + const val COUPLE_BIRTHDAY = "/v1/couple/birthday" +} diff --git a/src/main/kotlin/gomushin/backend/couple/presentation/CoupleConnectController.kt b/src/main/kotlin/gomushin/backend/couple/presentation/CoupleConnectController.kt new file mode 100644 index 00000000..aa6c2bf2 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/presentation/CoupleConnectController.kt @@ -0,0 +1,62 @@ +package gomushin.backend.couple.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.dto.request.CoupleAnniversaryRequest +import gomushin.backend.couple.dto.request.CoupleConnectRequest +import gomushin.backend.couple.facade.CoupleFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +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.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "커플 코드 생성", description = "CoupleConnectController") +class CoupleConnectController( + private val coupleFacade: CoupleFacade +) { + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(ApiPath.COUPLE_CODE_GENERATE) + @Operation( + summary = "커플 코드를 생성합니다. 커플 코드는 60분 동안 유효합니다.", + description = "generateCoupleCode" + ) + fun generateCoupleCode( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ): ApiResponse = + ApiResponse.success(coupleFacade.requestCoupleCodeGeneration(customUserDetails)) + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(ApiPath.COUPLE_CONNECT) + @Operation( + summary = "커플 코드를 통해 남자친구(여자친구)와 연결합니다.", + description = "connectCouple" + ) + fun connectCouple( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody request: CoupleConnectRequest, + ): ApiResponse { + val couple = coupleFacade.requestCoupleConnect(customUserDetails, request) + return ApiResponse.success(couple) + } + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(ApiPath.COUPLE_ANNIVERSARY) + @Operation( + summary = "커플 기념일(처음 만난 날, 입대일, 전역일)을 등록합니다.(이건 수정 아니고, 초기 등록)", + description = "registerAnniversary" + ) + fun registerAnniversary( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody request: CoupleAnniversaryRequest, + ): ApiResponse { + coupleFacade.registerAnniversary(customUserDetails, request) + return ApiResponse.success(true) + } +} diff --git a/src/main/kotlin/gomushin/backend/couple/presentation/CoupleInfoController.kt b/src/main/kotlin/gomushin/backend/couple/presentation/CoupleInfoController.kt new file mode 100644 index 00000000..819227ca --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/presentation/CoupleInfoController.kt @@ -0,0 +1,84 @@ +package gomushin.backend.couple.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.couple.dto.response.* +import gomushin.backend.couple.facade.CoupleFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "커플 정보 불러오기", description = "CoupleInfoController") +class CoupleInfoController ( + private val coupleFacade: CoupleFacade +) { + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.COUPLE_PROFILE) + @Operation(summary = "grade 조회 api", description = "getGrade") + fun getGrade( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ): ApiResponse { + val grade = coupleFacade.getGradeInfo(customUserDetails) + return ApiResponse.success(grade) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.COUPLE_CHECK_CONNECT) + @Operation(summary = "커플 연동 여부 체크 api", description = "coupleConnectCheck") + fun coupleConnectCheck( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ): ApiResponse { + return ApiResponse.success(coupleFacade.checkConnect(customUserDetails)) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.COUPLE_DDAY_INFO) + @Operation(summary = "디데이 정보 조회 api", description = "getDday") + fun getDday( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ):ApiResponse { + return ApiResponse.success(coupleFacade.getDday(customUserDetails)) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.COUPLE_NICKNAME) + @Operation(summary = "닉네임 조회 api", description = "getNickName") + fun getNickName( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ):ApiResponse{ + return ApiResponse.success(coupleFacade.nickName(customUserDetails)) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.COUPLE_STATUS_MESSAGE) + @Operation(summary = "상태 메시지 조회 api", description = "getStatusMessage") + fun getStatusMessage( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ):ApiResponse{ + return ApiResponse.success(coupleFacade.statusMessage(customUserDetails)) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.COUPLE_EMOJI) + @Operation(summary = "커플 이모지 조회 api", description = "getCoupleEmotion") + fun getCoupleEmotion( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ):ApiResponse{ + return ApiResponse.success(coupleFacade.getCoupleEmotion(customUserDetails)) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.COUPLE_BIRTHDAY) + @Operation(summary = "커플 생년월일 조회 api", description = "getCoupleBirthDay") + fun getCoupleBirthDay( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ) :ApiResponse{ + val coupleBirthDay = coupleFacade.getCoupleBirthDay(customUserDetails) + return ApiResponse.success(coupleBirthDay) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/presentation/CoupleUpdateController.kt b/src/main/kotlin/gomushin/backend/couple/presentation/CoupleUpdateController.kt new file mode 100644 index 00000000..0628d5af --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/presentation/CoupleUpdateController.kt @@ -0,0 +1,43 @@ +package gomushin.backend.couple.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.couple.dto.request.UpdateMilitaryDateRequest +import gomushin.backend.couple.dto.request.UpdateRelationshipStartDateRequest +import gomushin.backend.couple.facade.CoupleFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +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.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "커플 정보 수정", description = "CoupleUpdateController") +class CoupleUpdateController( + private val coupleFacade: CoupleFacade +) { + @ResponseStatus(HttpStatus.OK) + @PostMapping(ApiPath.COUPLE_UPDATE_MILITARY_DATE) + @Operation(summary = "입대, 전역일 수정 api", description = "updateMilitaryDate") + fun updateMilitaryDate( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody updateMilitaryDateRequest: UpdateMilitaryDateRequest + ) : ApiResponse { + coupleFacade.updateMilitaryDate(customUserDetails, updateMilitaryDateRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @PostMapping(ApiPath.COUPLE_UPDATE_RELATIONSHIP_DATE) + @Operation(summary = "만난날 수정 api", description = "updateRelationshipStartDate") + fun updateRelationshipStartDate( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody updateRelationshipStartDateRequest: UpdateRelationshipStartDateRequest + ) : ApiResponse { + coupleFacade.updateRelationshipStartDate(customUserDetails, updateRelationshipStartDateRequest) + return ApiResponse.success(true) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/couple/presentation/ReadCoupleController.kt b/src/main/kotlin/gomushin/backend/couple/presentation/ReadCoupleController.kt new file mode 100644 index 00000000..20f7c9cc --- /dev/null +++ b/src/main/kotlin/gomushin/backend/couple/presentation/ReadCoupleController.kt @@ -0,0 +1,29 @@ +package gomushin.backend.couple.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.couple.dto.response.CoupleInfoResponse +import gomushin.backend.couple.facade.CoupleFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "커플 조회", description = "ReadCoupleController") +class ReadCoupleController( + private val coupleFacade: CoupleFacade, +) { + + @GetMapping(ApiPath.COUPLE) + @Operation( + summary = "커플 정보 조회", + description = "getCoupleInfo" + ) + fun getCoupleInfo( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ): ApiResponse { + return ApiResponse.success(coupleFacade.getInfo(customUserDetails)) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/entity/Member.kt b/src/main/kotlin/gomushin/backend/member/domain/entity/Member.kt new file mode 100644 index 00000000..14bb7df8 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/entity/Member.kt @@ -0,0 +1,109 @@ +package gomushin.backend.member.domain.entity + +import gomushin.backend.core.infrastructure.jpa.shared.BaseEntity +import gomushin.backend.member.domain.value.Emotion +import gomushin.backend.member.domain.value.Provider +import gomushin.backend.member.domain.value.Role +import jakarta.persistence.* +import java.time.LocalDate + +@Entity +@Table(name = "member") +class Member( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(name = "name", nullable = false) + var name: String, + + @Column(name = "nickname", nullable = false) + var nickname: String, + + @Column(name = "email", unique = true, nullable = false) + var email: String, + + @Column(name = "birth_date") + var birthDate: LocalDate? = null, + + @Column(name = "status_message") + var statusMessage: String? = null, + + @Column(name = "profile_image_url") + var profileImageUrl: String?, + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false) + val provider: Provider, + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + var role: Role = Role.GUEST, + + @Column(name = "is_couple", nullable = false) + var isCouple: Boolean = false, + + @Enumerated(EnumType.STRING) + @Column(name = "emotion") + var emotion: Emotion? = null, + + @Column(name = "fcm_token", nullable = false) + var fcmToken: String = "", + + ) : BaseEntity() { + companion object { + private const val EMPTY_STATUS_MESSAGE = "" + + fun create( + name: String, + nickname: String?, + email: String, + profileImageUrl: String?, + provider: Provider, + ): Member { + return Member( + name = name, + nickname = nickname ?: name, + email = email, + profileImageUrl = profileImageUrl ?: "", + provider = provider, + ) + } + } + + fun checkIsCouple(): Boolean { + return isCouple + } + + fun updateFcmToken(fcmToken: String) { + this.fcmToken = fcmToken + } + + fun updateCoupleStatus() { + this.isCouple = !this.isCouple + } + + fun updateEmotion(emotion: Emotion) { + this.emotion = emotion + } + + fun updateStatusMessage(statusMessage: String) { + this.statusMessage = statusMessage + } + + fun updateNickname(nickname: String) { + this.nickname = nickname + } + + fun updateBirthday(birthDate: LocalDate) { + this.birthDate = birthDate + } + + fun updateIsCouple(isCouple: Boolean) { + this.isCouple = isCouple + } + + fun clearStatusMessage() { + this.statusMessage = EMPTY_STATUS_MESSAGE + } +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/entity/Notification.kt b/src/main/kotlin/gomushin/backend/member/domain/entity/Notification.kt new file mode 100644 index 00000000..4e96156c --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/entity/Notification.kt @@ -0,0 +1,40 @@ +package gomushin.backend.member.domain.entity + +import gomushin.backend.core.infrastructure.jpa.shared.BaseEntity +import jakarta.persistence.* + +@Entity +@Table(name = "notification") +class Notification( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(name = "member_id", nullable = false) + val memberId: Long, + + @Column(name = "dday", nullable = false) + var dday: Boolean = false, + + @Column(name = "partner_status", nullable = false) + var partnerStatus: Boolean = false, + + ) : BaseEntity() { + companion object { + fun create( + memberId: Long, + ): Notification { + return Notification( + memberId = memberId + ) + } + } + + fun updateDday(dday: Boolean) { + this.dday = dday + } + + fun updatePartnerStatus(partnerStatus: Boolean) { + this.partnerStatus = partnerStatus + } +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/repository/MemberRepository.kt b/src/main/kotlin/gomushin/backend/member/domain/repository/MemberRepository.kt new file mode 100644 index 00000000..d2a66c79 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/repository/MemberRepository.kt @@ -0,0 +1,18 @@ +package gomushin.backend.member.domain.repository + +import gomushin.backend.member.domain.entity.Member +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query + +interface MemberRepository : JpaRepository { + fun findByEmail(email: String): Member? + @Query( + """ + SELECT m FROM Member m + JOIN Notification n ON n.memberId = m.id + WHERE m.isCouple = true + AND NOT (n.dday = false AND n.partnerStatus = false) + """ + ) + fun findCoupleMembersWithEnabledNotification(): List +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/repository/NotificationRepository.kt b/src/main/kotlin/gomushin/backend/member/domain/repository/NotificationRepository.kt new file mode 100644 index 00000000..9ba781ef --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/repository/NotificationRepository.kt @@ -0,0 +1,10 @@ +package gomushin.backend.member.domain.repository + +import gomushin.backend.member.domain.entity.Notification +import org.springframework.data.jpa.repository.JpaRepository + +interface NotificationRepository : JpaRepository { + fun findByMemberId(memberId: Long): Notification? + + fun deleteAllByMemberId(memberId: Long) +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/service/MemberService.kt b/src/main/kotlin/gomushin/backend/member/domain/service/MemberService.kt new file mode 100644 index 00000000..911f1c79 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/service/MemberService.kt @@ -0,0 +1,62 @@ +package gomushin.backend.member.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.repository.MemberRepository +import gomushin.backend.member.dto.request.UpdateMyBirthdayRequest +import gomushin.backend.member.dto.request.UpdateMyEmotionAndStatusMessageRequest +import gomushin.backend.member.dto.request.UpdateMyNickNameRequest +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class MemberService( + private val memberRepository: MemberRepository, +) { + + @Transactional(readOnly = true) + fun getById(id: Long): Member { + return findById(id) ?: throw BadRequestException("sarangggun.member.not-exist-member") + } + + @Transactional(readOnly = true) + fun findById(id: Long): Member? { + return memberRepository.findByIdOrNull(id) + } + + @Transactional + fun updateMyEmotionAndStatusMessage(id: Long, updateMyEmotionAndStatusMessageRequest: UpdateMyEmotionAndStatusMessageRequest) { + val member = getById(id) + member.updateEmotion(updateMyEmotionAndStatusMessageRequest.emotion) + member.updateStatusMessage(updateMyEmotionAndStatusMessageRequest.statusMessage) + } + + @Transactional + fun updateMyNickname(id: Long, updateMyNickNameRequest: UpdateMyNickNameRequest) { + val member = getById(id) + member.updateNickname(updateMyNickNameRequest.nickname) + } + + @Transactional + fun updateMyBirthDate(id: Long, updateMyBirthdayRequest: UpdateMyBirthdayRequest) { + val member = getById(id) + member.updateBirthday(updateMyBirthdayRequest.birthDate) + } + + @Transactional + fun deleteMember(id : Long) { + memberRepository.deleteById(id) + } + + @Transactional(readOnly = true) + fun getAllCoupledMemberWithEnabledNotification() : List{ + return memberRepository.findCoupleMembersWithEnabledNotification() + } + + @Transactional + fun clearMemberStatusMessage(id: Long) { + val member = getById(id) + member.clearStatusMessage() + } +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/service/NotificationService.kt b/src/main/kotlin/gomushin/backend/member/domain/service/NotificationService.kt new file mode 100644 index 00000000..3c5b7d96 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/service/NotificationService.kt @@ -0,0 +1,41 @@ +package gomushin.backend.member.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.member.domain.entity.Notification +import gomushin.backend.member.domain.repository.NotificationRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class NotificationService( + private val notificationRepository: NotificationRepository +) { + + @Transactional + fun initNotification(memberId: Long, isNotification: Boolean) { + val notification = Notification.create(memberId) + notification.updateDday(isNotification) + notification.updatePartnerStatus(isNotification) + save(notification) + } + + @Transactional(readOnly = true) + fun getByMemberId(memberId: Long): Notification { + return findByMemberId(memberId) ?: throw BadRequestException("sarangggun.member.not-exist-member") + } + + @Transactional(readOnly = true) + fun findByMemberId(memberId: Long): Notification? { + return notificationRepository.findByMemberId(memberId) + } + + @Transactional + fun save(notification: Notification): Notification { + return notificationRepository.save(notification) + } + + @Transactional + fun deleteAllByMember(memberId: Long) { + notificationRepository.deleteAllByMemberId(memberId) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/service/OnboardingService.kt b/src/main/kotlin/gomushin/backend/member/domain/service/OnboardingService.kt new file mode 100644 index 00000000..1e5299d8 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/service/OnboardingService.kt @@ -0,0 +1,34 @@ +package gomushin.backend.member.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.repository.MemberRepository +import gomushin.backend.member.domain.value.Role +import gomushin.backend.member.dto.request.OnboardingRequest +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class OnboardingService( + private val memberRepository: MemberRepository, +) { + @Transactional + fun onboarding(id: Long, onboardingRequest: OnboardingRequest) { + val member = getById(id) + member.nickname = onboardingRequest.nickname + member.birthDate = onboardingRequest.birthDate + member.role = Role.MEMBER + member.fcmToken = onboardingRequest.fcmToken + } + + @Transactional(readOnly = true) + fun getById(id: Long): Member { + return findById(id) ?: throw BadRequestException("sarangggun.member.not-exist-member") + } + + @Transactional(readOnly = true) + fun findById(id: Long): Member? { + return memberRepository.findByIdOrNull(id) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/value/Emotion.kt b/src/main/kotlin/gomushin/backend/member/domain/value/Emotion.kt new file mode 100644 index 00000000..e9942f25 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/value/Emotion.kt @@ -0,0 +1,6 @@ +package gomushin.backend.member.domain.value + + +enum class Emotion { + ANGRY, WORRY, HAPPY, MISS, COMMON, SAD, TIRED; +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/member/domain/value/Gender.kt b/src/main/kotlin/gomushin/backend/member/domain/value/Gender.kt new file mode 100644 index 00000000..ccd41f2c --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/value/Gender.kt @@ -0,0 +1,6 @@ +package gomushin.backend.member.domain.value + +enum class Gender { + MALE, + FEMALE +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/value/Provider.kt b/src/main/kotlin/gomushin/backend/member/domain/value/Provider.kt new file mode 100644 index 00000000..2c0291fe --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/value/Provider.kt @@ -0,0 +1,18 @@ +package gomushin.backend.member.domain.value + +import gomushin.backend.core.infrastructure.exception.BadRequestException + +enum class Provider( + private val value: String +) { + GOOGLE("google"), + KAKAO("kakao"), + NAVER("naver"); + + companion object { + fun getProviderByValue(value: String): Provider { + return entries.firstOrNull { it.value == value } + ?: throw BadRequestException("sarangggun.oauth.invalid-provider") + } + } +} diff --git a/src/main/kotlin/gomushin/backend/member/domain/value/Role.kt b/src/main/kotlin/gomushin/backend/member/domain/value/Role.kt new file mode 100644 index 00000000..41c7fa5d --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/domain/value/Role.kt @@ -0,0 +1,15 @@ +package gomushin.backend.member.domain.value + +import gomushin.backend.core.infrastructure.exception.BadRequestException + +enum class Role { + GUEST, + MEMBER; + + companion object { + fun getByName(name: String): Role { + return entries.firstOrNull { it.name == name } + ?: throw BadRequestException("sarangggun.member.not-exist-role") + } + } +} diff --git a/src/main/kotlin/gomushin/backend/member/dto/request/OnboardingRequest.kt b/src/main/kotlin/gomushin/backend/member/dto/request/OnboardingRequest.kt new file mode 100644 index 00000000..616b2313 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/request/OnboardingRequest.kt @@ -0,0 +1,18 @@ +package gomushin.backend.member.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class OnboardingRequest( + @Schema(description = "닉네임", example = "nickname") + val nickname: String, + + @Schema(description = "생일", example = "2000-01-01") + val birthDate: LocalDate, + + @Schema(description = "FCM 토큰") + val fcmToken: String, + + @Schema(description = "알림 설정 여부", example = "false") + val isNotification: Boolean, +) diff --git a/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyBirthdayRequest.kt b/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyBirthdayRequest.kt new file mode 100644 index 00000000..3be0c1b7 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyBirthdayRequest.kt @@ -0,0 +1,9 @@ +package gomushin.backend.member.dto.request + +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDate + +data class UpdateMyBirthdayRequest ( + @Schema(description = "생일", example = "2001-03-27") + val birthDate : LocalDate +) \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyEmotionAndStatusMessageRequest.kt b/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyEmotionAndStatusMessageRequest.kt new file mode 100644 index 00000000..acb3445f --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyEmotionAndStatusMessageRequest.kt @@ -0,0 +1,17 @@ +package gomushin.backend.member.dto.request + +import gomushin.backend.member.domain.value.Emotion +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated + + +data class UpdateMyEmotionAndStatusMessageRequest( + @Enumerated(EnumType.STRING) + @Schema(description = "이모지(MISS : 보고싶어요, GOOD: 기분 좋아요, COMMON : 아무느낌 없어요, " + + "TIRED : 피곤해요, SAD: 서운해요, WORRY : 걱정돼요, ANGRY : 짜증나요)", example = "COMMON") + val emotion : Emotion, + + @Schema(description = "상태 메시지", example = "보고 싶어요") + val statusMessage : String +) diff --git a/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyNickNameRequest.kt b/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyNickNameRequest.kt new file mode 100644 index 00000000..1ee03919 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyNickNameRequest.kt @@ -0,0 +1,8 @@ +package gomushin.backend.member.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpdateMyNickNameRequest ( + @Schema(description = "닉네임", example = "김꽃신") + val nickname : String +) \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyNotificationRequest.kt b/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyNotificationRequest.kt new file mode 100644 index 00000000..ec236f06 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/request/UpdateMyNotificationRequest.kt @@ -0,0 +1,10 @@ +package gomushin.backend.member.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpdateMyNotificationRequest ( + @Schema(description = "디데이 알림 설정", example = "true") + val dday : Boolean, + @Schema(description = "연인상태 알림 설정", example = "false") + val partnerStatus : Boolean +) \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/member/dto/response/MyEmotionResponse.kt b/src/main/kotlin/gomushin/backend/member/dto/response/MyEmotionResponse.kt new file mode 100644 index 00000000..6fb9be58 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/response/MyEmotionResponse.kt @@ -0,0 +1,14 @@ +package gomushin.backend.member.dto.response + +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.value.Emotion + +data class MyEmotionResponse ( + val emotion : Emotion? +) { + companion object { + fun of(member: Member) = MyEmotionResponse ( + member.emotion + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/member/dto/response/MyInfoResponse.kt b/src/main/kotlin/gomushin/backend/member/dto/response/MyInfoResponse.kt new file mode 100644 index 00000000..83c0b960 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/response/MyInfoResponse.kt @@ -0,0 +1,21 @@ +package gomushin.backend.member.dto.response + +import gomushin.backend.member.domain.entity.Member +import io.swagger.v3.oas.annotations.media.Schema + +data class MyInfoResponse( + @Schema(description = "내 닉네임", example = "닉네임") + val nickname: String, + @Schema(description = "커플 여부", example = "true") + val isCouple: Boolean, + @Schema(description = "온보딩 여부 (GUEST : 온보딩 X , MEMBER : 온보딩 O)", example = "MEMBER") + val role: String +) { + companion object { + fun of(member: Member) = MyInfoResponse( + member.nickname, + member.isCouple, + member.role.name + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/dto/response/MyNotificationResponse.kt b/src/main/kotlin/gomushin/backend/member/dto/response/MyNotificationResponse.kt new file mode 100644 index 00000000..e2db13a5 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/response/MyNotificationResponse.kt @@ -0,0 +1,18 @@ +package gomushin.backend.member.dto.response + +import gomushin.backend.member.domain.entity.Notification +import io.swagger.v3.oas.annotations.media.Schema + +data class MyNotificationResponse( + @Schema(description = "디데이 알림 설정 정보", example = "true") + val dday : Boolean, + @Schema(description = "상태 알림 설정 정보", example = "false") + val partnerStatus : Boolean +) { + companion object { + fun of(notification: Notification) = MyNotificationResponse ( + notification.dday, + notification.partnerStatus + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/dto/response/MyStatusMessageResponse.kt b/src/main/kotlin/gomushin/backend/member/dto/response/MyStatusMessageResponse.kt new file mode 100644 index 00000000..f210fdb5 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/dto/response/MyStatusMessageResponse.kt @@ -0,0 +1,13 @@ +package gomushin.backend.member.dto.response + +import gomushin.backend.member.domain.entity.Member + +data class MyStatusMessageResponse ( + val statusMessage : String? +) { + companion object { + fun of(member: Member) = MyStatusMessageResponse( + member.statusMessage + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/gomushin/backend/member/facade/LeaveFacade.kt b/src/main/kotlin/gomushin/backend/member/facade/LeaveFacade.kt new file mode 100644 index 00000000..a7b0834b --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/facade/LeaveFacade.kt @@ -0,0 +1,101 @@ +package gomushin.backend.member.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.event.dto.S3DeleteEvent +import gomushin.backend.couple.domain.service.AnniversaryService +import gomushin.backend.couple.domain.service.CoupleInfoService +import gomushin.backend.couple.domain.service.CoupleService +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.member.domain.service.NotificationService +import gomushin.backend.schedule.domain.service.CommentService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class LeaveFacade( + private val anniversaryService: AnniversaryService, + private val commentService: CommentService, + private val coupleService: CoupleService, + private val letterService: LetterService, + private val memberService: MemberService, + private val notificationService: NotificationService, + private val pictureService: PictureService, + private val scheduleService: ScheduleService, + private val coupleInfoService: CoupleInfoService, + private val applicationEventPublisher: ApplicationEventPublisher, +) { + @Transactional + fun leave(customUserDetails: CustomUserDetails) { + val memberId = customUserDetails.getId() + val coupleId = customUserDetails.getCouple().id + val partner = coupleInfoService.findCoupleMember(memberId) + anniversaryService.deleteAllByCoupleId(coupleId) + deleteComments(memberId, partner.id) + coupleService.deleteByMemberId(memberId) + deleteNotifications(memberId, partner.id) + deleteSchedules(memberId, partner.id) + + val memberLetters = letterService.findAllByAuthorId(memberId) + val partnerLetters = letterService.findAllByAuthorId(partner.id) + val pictureUrlsToDelete = mutableListOf() + + pictureService.findAllByLetterIds(memberLetters) + .takeIf { it.isNotEmpty() } + ?.forEach { picture -> pictureUrlsToDelete.add(picture.pictureUrl) } + pictureService.findAllByLetterIds(partnerLetters) + .takeIf { it.isNotEmpty() } + ?.forEach { picture -> pictureUrlsToDelete.add(picture.pictureUrl) } + + deleteAllPictures(memberLetters, partnerLetters) + deleteAllLetters(memberId, partner.id) + + clearCoupleStatusMessages(memberId, partner.id) + + partner.updateIsCouple(false) + + memberService.deleteMember(memberId) + + if (pictureUrlsToDelete.isNotEmpty()) { + applicationEventPublisher.publishEvent( + S3DeleteEvent( + pictureUrls = pictureUrlsToDelete + ) + ) + } + } + + private fun deleteComments(memberId: Long, partnerId: Long) { + commentService.deleteAllByMemberId(memberId) + commentService.deleteAllByMemberId(partnerId) + } + + private fun deleteNotifications(memberId: Long, partnerId: Long) { + notificationService.deleteAllByMember(memberId) + notificationService.deleteAllByMember(partnerId) + } + + private fun deleteSchedules(memberId: Long, partnerId: Long) { + scheduleService.deleteAllByMemberId(memberId) + scheduleService.deleteAllByMemberId(partnerId) + } + + private fun deleteAllPictures(memberLetterIds: List, partnerLetterIds: List) { + pictureService.deleteAllByLetterIds(memberLetterIds) + pictureService.deleteAllByLetterIds(partnerLetterIds) + } + + private fun deleteAllLetters(memberId: Long, partnerId: Long) { + letterService.deleteAllByMemberId(memberId) + letterService.deleteAllByMemberId(partnerId) + } + + private fun clearCoupleStatusMessages(memberId: Long, partnerId: Long) { + memberService.clearMemberStatusMessage(memberId) + memberService.clearMemberStatusMessage(partnerId) + } +} + diff --git a/src/main/kotlin/gomushin/backend/member/facade/MemberInfoFacade.kt b/src/main/kotlin/gomushin/backend/member/facade/MemberInfoFacade.kt new file mode 100644 index 00000000..025bb860 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/facade/MemberInfoFacade.kt @@ -0,0 +1,76 @@ +package gomushin.backend.member.facade + +import gomushin.backend.alarm.dto.SaveAlarmMessage +import gomushin.backend.alarm.service.StatusAlarmService +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.alarm.service.NotificationRedisService +import gomushin.backend.couple.domain.service.CoupleInfoService +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.member.domain.service.NotificationService +import gomushin.backend.member.dto.request.UpdateMyBirthdayRequest +import gomushin.backend.member.dto.request.UpdateMyEmotionAndStatusMessageRequest +import gomushin.backend.member.dto.request.UpdateMyNickNameRequest +import gomushin.backend.member.dto.request.UpdateMyNotificationRequest +import gomushin.backend.member.dto.response.MyEmotionResponse +import gomushin.backend.member.dto.response.MyInfoResponse +import gomushin.backend.member.dto.response.MyNotificationResponse +import gomushin.backend.member.dto.response.MyStatusMessageResponse +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class MemberInfoFacade( + private val memberService: MemberService, + private val notificationService: NotificationService, + private val statusAlarmService: StatusAlarmService, + private val coupleInfoService: CoupleInfoService, + private val notificationRedisService: NotificationRedisService +) { + fun getMemberInfo(customUserDetails: CustomUserDetails): MyInfoResponse { + val member = memberService.getById(customUserDetails.getId()) + return MyInfoResponse.of(member) + } + + fun getMyStatusMessage(customUserDetails: CustomUserDetails): MyStatusMessageResponse { + val member = memberService.getById(customUserDetails.getId()) + return MyStatusMessageResponse.of(member) + } + + fun updateMyEmotionAndStatusMessage(customUserDetails: CustomUserDetails, updateMyEmotionAndStatusMessageRequest: UpdateMyEmotionAndStatusMessageRequest) { + memberService.updateMyEmotionAndStatusMessage(customUserDetails.getId(), updateMyEmotionAndStatusMessageRequest) + coupleInfoService.findCoupleMember(customUserDetails.getId()).also { receiver -> + if (notificationService.getByMemberId(receiver.id).partnerStatus) { + val sender = memberService.getById(customUserDetails.getId()) + statusAlarmService.sendStatusAlarm(sender, receiver, updateMyEmotionAndStatusMessageRequest.emotion) + } + } + } + + fun getMemberEmotion(customUserDetails: CustomUserDetails): MyEmotionResponse { + val member = memberService.getById(customUserDetails.getId()) + return MyEmotionResponse.of(member) + } + + fun updateMyNickname(customUserDetails: CustomUserDetails, updateMyNickNameRequest: UpdateMyNickNameRequest) + = memberService.updateMyNickname(customUserDetails.getId(), updateMyNickNameRequest) + + fun updateMyBirthDate(customUserDetails: CustomUserDetails, updateMyBirthdayRequest: UpdateMyBirthdayRequest) + = memberService.updateMyBirthDate(customUserDetails.getId(), updateMyBirthdayRequest) + + @Transactional + fun updateMyNotification(customUserDetails: CustomUserDetails, updateMyNotificationRequest: UpdateMyNotificationRequest) { + notificationService.getByMemberId(customUserDetails.getId()).apply { + updateDday(updateMyNotificationRequest.dday) + updatePartnerStatus(updateMyNotificationRequest.partnerStatus) + } + } + + fun getMyNotification(customUserDetails: CustomUserDetails): MyNotificationResponse { + val notification = notificationService.getByMemberId(customUserDetails.getId()) + return MyNotificationResponse.of(notification) + } + + fun getMyReceivedNotification(customUserDetails: CustomUserDetails, recentDays: Long): List { + return notificationRedisService.getAlarms(customUserDetails.getId(), recentDays) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/facade/OnboardingFacade.kt b/src/main/kotlin/gomushin/backend/member/facade/OnboardingFacade.kt new file mode 100644 index 00000000..18c71377 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/facade/OnboardingFacade.kt @@ -0,0 +1,18 @@ +package gomushin.backend.member.facade + +import gomushin.backend.member.domain.service.NotificationService +import gomushin.backend.member.domain.service.OnboardingService +import gomushin.backend.member.dto.request.OnboardingRequest +import org.springframework.stereotype.Component + +@Component +class OnboardingFacade( + private val onboardingService: OnboardingService, + private val notificationService: NotificationService, +) { + + fun onboarding(id: Long, onboardingRequest: OnboardingRequest) { + onboardingService.onboarding(id, onboardingRequest) + notificationService.initNotification(id, onboardingRequest.isNotification) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/presentation/ApiPath.kt b/src/main/kotlin/gomushin/backend/member/presentation/ApiPath.kt new file mode 100644 index 00000000..a6c12924 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/presentation/ApiPath.kt @@ -0,0 +1,15 @@ +package gomushin.backend.member.presentation + +object ApiPath { + const val ONBOARDING = "/v1/member/onboarding" + const val MY_INFO = "/v1/member/my-info" + const val MY_STATUS_MESSAGE = "/v1/member/my-status-message" + const val UPDATE_MY_EMOTION_AND_STATUS_MESSAGE = "/v1/member/my-emotion-and-status-message" + const val MY_EMOTION = "/v1/member/my-emotion" + const val UPDATE_MY_NICKNAME = "/v1/member/my-nickname" + const val UPDATE_MY_BIRTHDAY = "/v1/member/my-birthday" + const val UPDATE_MY_NOTIFICATION_POLICY = "/v1/member/my-notification" + const val DELETE_ALL_MY_DATA ="/v1/member/all-my-data" + const val MY_NOTIFICATION = "/v1/member/my-notification" + const val MY_ALARM = "/v1/member/my-alarm/{recentDays}" +} diff --git a/src/main/kotlin/gomushin/backend/member/presentation/MemberInfoController.kt b/src/main/kotlin/gomushin/backend/member/presentation/MemberInfoController.kt new file mode 100644 index 00000000..60e78fd8 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/presentation/MemberInfoController.kt @@ -0,0 +1,139 @@ +package gomushin.backend.member.presentation + +import gomushin.backend.alarm.dto.SaveAlarmMessage +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.member.dto.request.UpdateMyBirthdayRequest +import gomushin.backend.member.dto.request.UpdateMyEmotionAndStatusMessageRequest +import gomushin.backend.member.dto.request.UpdateMyNickNameRequest +import gomushin.backend.member.dto.request.UpdateMyNotificationRequest +import gomushin.backend.member.dto.response.MyEmotionResponse +import gomushin.backend.member.facade.MemberInfoFacade +import gomushin.backend.member.dto.response.MyInfoResponse +import gomushin.backend.member.dto.response.MyNotificationResponse +import gomushin.backend.member.dto.response.MyStatusMessageResponse +import gomushin.backend.member.facade.LeaveFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +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.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "회원 정보", description = "MemberController") +class MemberInfoController( + private val memberInfoFacade: MemberInfoFacade, + private val leaveFacade: LeaveFacade +) { + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.MY_INFO) + @Operation(summary = "내 정보 조회", description = "getMyInfo") + fun get( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ): ApiResponse { + val member = memberInfoFacade.getMemberInfo(customUserDetails) + return ApiResponse.success(member) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.MY_STATUS_MESSAGE) + @Operation(summary = "내 상태 메시지 조회", description = "getMyStatusMessage") + fun getMyStatusMessage( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ): ApiResponse { + val statusMessage = memberInfoFacade.getMyStatusMessage(customUserDetails) + return ApiResponse.success(statusMessage) + } + + @ResponseStatus(HttpStatus.OK) + @PostMapping(ApiPath.UPDATE_MY_EMOTION_AND_STATUS_MESSAGE) + @Operation(summary = "내 상태 이모지 및 상태 메시지 저장", description = "updateMyEmotionAndStatusMessage") + fun updateMyEmotionAndStatusMessage( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody updateMyEmotionAndStatusMessageRequest: UpdateMyEmotionAndStatusMessageRequest + ): ApiResponse { + memberInfoFacade.updateMyEmotionAndStatusMessage(customUserDetails, updateMyEmotionAndStatusMessageRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.MY_EMOTION) + @Operation(summary = "내 상태 이모지 조회", description = "getMyEmotion") + fun getMyEmotion( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ):ApiResponse { + val emotion = memberInfoFacade.getMemberEmotion(customUserDetails) + return ApiResponse.success(emotion) + } + + @ResponseStatus(HttpStatus.OK) + @PostMapping(ApiPath.UPDATE_MY_NICKNAME) + @Operation(summary = "내 닉네임 수정", description = "updateMyNickname") + fun updateMyNickname( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody updateMyNickNameRequest: UpdateMyNickNameRequest + ):ApiResponse { + memberInfoFacade.updateMyNickname(customUserDetails, updateMyNickNameRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @PostMapping(ApiPath.UPDATE_MY_BIRTHDAY) + @Operation(summary = "내 생일 수정", description = "updateMyBirthDate") + fun updateMyBirthDate( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody updateMyBirthdayRequest : UpdateMyBirthdayRequest + ):ApiResponse { + memberInfoFacade.updateMyBirthDate(customUserDetails, updateMyBirthdayRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @PostMapping(ApiPath.UPDATE_MY_NOTIFICATION_POLICY) + @Operation(summary = "내 알림 정책 수정", description = "updateMyNotification") + fun updateMyNotification( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody updateMyNotificationRequest: UpdateMyNotificationRequest + ):ApiResponse { + memberInfoFacade.updateMyNotification(customUserDetails, updateMyNotificationRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping(ApiPath.DELETE_ALL_MY_DATA) + @Operation(summary = "커플 연동 해제(유저 관련 데이터 모두 삭제)", description = "deleteMyData") + fun deleteMyData( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ) : ApiResponse { + leaveFacade.leave(customUserDetails) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.MY_NOTIFICATION) + @Operation(summary = "나의 알림 정책 조회", description = "getMyNotification") + fun getMyNotification( + @AuthenticationPrincipal customUserDetails: CustomUserDetails + ) : ApiResponse { + val myNotification = memberInfoFacade.getMyNotification(customUserDetails) + return ApiResponse.success(myNotification) + } + + @ResponseStatus(HttpStatus.OK) + @GetMapping(ApiPath.MY_ALARM) + @Operation(summary = "알림 수신 내역 조회", description = "getMyReceivedNotification") + fun getMyReceivedNotification( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable recentDays : Long + ) : ApiResponse>{ + val saveAlarmMessages = memberInfoFacade.getMyReceivedNotification(customUserDetails, recentDays) + return ApiResponse.success(saveAlarmMessages) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/presentation/OnboardingController.kt b/src/main/kotlin/gomushin/backend/member/presentation/OnboardingController.kt new file mode 100644 index 00000000..5101aa3a --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/presentation/OnboardingController.kt @@ -0,0 +1,32 @@ +package gomushin.backend.member.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.member.facade.OnboardingFacade +import gomushin.backend.member.dto.request.OnboardingRequest +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +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.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "온보딩", description = "OnboardingController") +class OnboardingController( + private val onboardingFacade: OnboardingFacade, +) { + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(ApiPath.ONBOARDING) + @Operation(summary = "온보딩", description = "onboarding") + fun onboarding( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody onboardingRequest: OnboardingRequest + ): ApiResponse { + onboardingFacade.onboarding(customUserDetails.getId(), onboardingRequest) + return ApiResponse.success(true) + } +} diff --git a/src/main/kotlin/gomushin/backend/member/util/CoupleCodeGeneratorUtil.kt b/src/main/kotlin/gomushin/backend/member/util/CoupleCodeGeneratorUtil.kt new file mode 100644 index 00000000..87b4190d --- /dev/null +++ b/src/main/kotlin/gomushin/backend/member/util/CoupleCodeGeneratorUtil.kt @@ -0,0 +1,10 @@ +package gomushin.backend.member.util + +object CoupleCodeGeneratorUtil { + fun generateCoupleCode(): String { + val characters = ('A'..'Z') + ('0'..'9') + return (1..6) + .map { characters.random() } + .joinToString("") + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/entity/Comment.kt b/src/main/kotlin/gomushin/backend/schedule/domain/entity/Comment.kt new file mode 100644 index 00000000..d6d7ca9b --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/entity/Comment.kt @@ -0,0 +1,40 @@ +package gomushin.backend.schedule.domain.entity + +import gomushin.backend.core.infrastructure.jpa.shared.BaseEntity +import jakarta.persistence.* + +@Entity +@Table(name = "comment") +class Comment( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(name = "letter_id", nullable = false) + val letterId: Long = 0L, + + @Column(name = "author_id", nullable = false) + val authorId: Long = 0L, + + @Column(name = "nickname", nullable = false) + var nickname: String = "", + + @Column(name = "content", nullable = false) + var content: String = "", +) : BaseEntity() { + companion object { + fun of( + letterId: Long, + authorId: Long, + nickname: String, + content: String, + ): Comment { + return Comment( + letterId = letterId, + authorId = authorId, + nickname = nickname, + content = content, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/entity/Letter.kt b/src/main/kotlin/gomushin/backend/schedule/domain/entity/Letter.kt new file mode 100644 index 00000000..f112d144 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/entity/Letter.kt @@ -0,0 +1,51 @@ +package gomushin.backend.schedule.domain.entity + +import gomushin.backend.core.infrastructure.jpa.shared.BaseEntity +import jakarta.persistence.* + +@Entity +@Table(name = "letter") +class Letter( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(name = "couple_id", nullable = false) + val coupleId: Long = 0L, + + @Column(name = "schedule_id", nullable = false) + val scheduleId: Long = 0L, + + @Column(name = "author_id", nullable = false) + val authorId: Long = 0L, + + @Column(name = "author", nullable = false) + val author: String = "", + + @Column(name = "title", nullable = false) + var title: String = "", + + @Column(name = "content", nullable = false) + var content: String = "", + + ) : BaseEntity() { + companion object { + fun of( + coupleId: Long, + scheduleId: Long, + authorId: Long, + author: String, + title: String, + content: String, + ): Letter { + return Letter( + coupleId = coupleId, + scheduleId = scheduleId, + authorId = authorId, + author = author, + title = title, + content = content, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/entity/Picture.kt b/src/main/kotlin/gomushin/backend/schedule/domain/entity/Picture.kt new file mode 100644 index 00000000..e166e910 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/entity/Picture.kt @@ -0,0 +1,29 @@ +package gomushin.backend.schedule.domain.entity + +import jakarta.persistence.* + +@Entity +@Table(name = "picture") +class Picture( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(name = "letter_id", nullable = false) + val letterId: Long = 0L, + + @Column(name = "picture_url", nullable = false) + val pictureUrl: String = "", +) { + companion object { + fun of( + letterId: Long, + pictureUrl: String, + ): Picture { + return Picture( + letterId = letterId, + pictureUrl = pictureUrl, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/entity/Schedule.kt b/src/main/kotlin/gomushin/backend/schedule/domain/entity/Schedule.kt new file mode 100644 index 00000000..24bde287 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/entity/Schedule.kt @@ -0,0 +1,66 @@ +package gomushin.backend.schedule.domain.entity + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.core.infrastructure.jpa.shared.BaseEntity +import jakarta.persistence.* +import java.time.LocalDateTime + +@Entity +@Table(name = "schedule") +class Schedule( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long = 0L, + + @Column(name = "couple_id", nullable = false) + val coupleId: Long = 0L, + + @Column(name = "user_id", nullable = false) + val userId: Long = 0L, + + @Column(name = "title", nullable = false) + var title: String, + + @Column(name = "start_date", nullable = false) + var startDate: LocalDateTime, + + @Column(name = "end_date", nullable = false) + var endDate: LocalDateTime, + + @Column(name = "is_all_day", nullable = false) + var isAllDay: Boolean = false, + + @Column(name = "fatigue", nullable = false) + var fatigue: String, +) : BaseEntity() { + + @PrePersist + @PreUpdate + fun validate() { + if (startDate.isAfter(endDate)) { + throw BadRequestException("sarangggun.schedule.invalid-date") + } + } + + companion object { + fun of( + coupleId: Long, + userId: Long, + title: String, + startDate: LocalDateTime, + endDate: LocalDateTime, + fatigue: String, + isAllDay: Boolean?, + ): Schedule { + return Schedule( + coupleId = coupleId, + userId = userId, + title = title, + startDate = startDate, + endDate = endDate, + fatigue = fatigue, + isAllDay = isAllDay ?: false, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/repository/CommentRepository.kt b/src/main/kotlin/gomushin/backend/schedule/domain/repository/CommentRepository.kt new file mode 100644 index 00000000..da5c45b4 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/repository/CommentRepository.kt @@ -0,0 +1,19 @@ +package gomushin.backend.schedule.domain.repository + +import gomushin.backend.schedule.domain.entity.Comment +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface CommentRepository : JpaRepository { + fun findAllByLetterId(letterId: Long): List + + @Modifying + @Query("DELETE FROM Comment c WHERE c.authorId = :authorId") + fun deleteAllByAuthorId(@Param("authorId") authorId: Long) + + @Modifying + @Query("DELETE FROM Comment c WHERE c.letterId = :letterId") + fun deleteAllByLetterId(letterId: Long) +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/repository/LetterRepository.kt b/src/main/kotlin/gomushin/backend/schedule/domain/repository/LetterRepository.kt new file mode 100644 index 00000000..9317e9cf --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/repository/LetterRepository.kt @@ -0,0 +1,38 @@ +package gomushin.backend.schedule.domain.repository + +import gomushin.backend.schedule.domain.entity.Letter +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface LetterRepository : JpaRepository { + fun findByCoupleId(coupleId: Long): List + + fun findByCoupleIdAndScheduleId(coupleId: Long, scheduleId: Long): List + + fun findByCoupleIdAndScheduleIdAndId(coupleId: Long, scheduleId: Long, letterId: Long): Letter? + + @Query("SELECT l.id FROM Letter l WHERE l.authorId = :authorId") + fun findAllByAuthorId(@Param("authorId") authorId: Long): List + + @Modifying + @Query("DELETE FROM Letter l WHERE l.authorId = :authorId") + fun deleteAllByAuthorId(@Param("authorId") authorId: Long) + + @Query( + """ + SELECT l FROM Letter l + WHERE l.coupleId = :coupleId + ORDER BY l.createdAt DESC + """, + ) + fun findAllToCouple( + @Param("coupleId") coupleId: Long, + pageable: Pageable + ): Page + + fun findTop5ByCoupleIdOrderByCreatedAtDesc(coupleId: Long): List +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/repository/PictureRepository.kt b/src/main/kotlin/gomushin/backend/schedule/domain/repository/PictureRepository.kt new file mode 100644 index 00000000..535d9e18 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/repository/PictureRepository.kt @@ -0,0 +1,24 @@ +package gomushin.backend.schedule.domain.repository + +import gomushin.backend.schedule.domain.entity.Picture +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface PictureRepository : JpaRepository { + fun findAllByPictureUrlIn(urls: List): List + fun findAllByLetterId(letterId: Long): List + fun findFirstByLetterIdOrderByIdAsc(letterId: Long): Picture? + + @Modifying + @Query("DELETE FROM Picture p WHERE p.letterId = :letterId") + fun deleteAllByLetterId(letterId: Long) + + @Query("SELECT p FROM Picture p WHERE p.letterId IN :letterIds") + fun findAllByLetterIdIn(@Param("letterIds") letterIds: List): List + + @Modifying + @Query("DELETE FROM Picture p WHERE p.letterId IN :letterIds") + fun deleteAllByLetterIdIn(@Param("letterIds") letterIds: List) +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/repository/ScheduleRepository.kt b/src/main/kotlin/gomushin/backend/schedule/domain/repository/ScheduleRepository.kt new file mode 100644 index 00000000..61cebd3e --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/repository/ScheduleRepository.kt @@ -0,0 +1,77 @@ +package gomushin.backend.schedule.domain.repository + +import gomushin.backend.schedule.domain.entity.Schedule +import gomushin.backend.schedule.dto.response.DailyScheduleResponse +import gomushin.backend.schedule.dto.response.MainSchedulesResponse +import gomushin.backend.schedule.dto.response.MonthlySchedulesResponse +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param +import java.time.LocalDate +import java.time.LocalDateTime + +interface ScheduleRepository : JpaRepository { + @Modifying + @Query("DELETE FROM Schedule s WHERE s.userId = :userId") + fun deleteAllByUserId(@Param("userId") userId: Long) + + @Query( + """ + SELECT new gomushin.backend.schedule.dto.response.MonthlySchedulesResponse( + s.id, + s.title, + s.startDate, + s.endDate, + s.fatigue + ) + FROM Schedule s + WHERE s.coupleId = :coupleId + AND ( + (function('YEAR', s.startDate) = :year AND function('MONTH', s.startDate) = :month) + OR + (function('YEAR', s.endDate) = :year AND function('MONTH', s.endDate) = :month) + ) + + """ + ) + fun findByCoupleIdAndYearAndMonth(coupleId: Long, year: Int, month: Int): List + + @Query( + """ + SELECT new gomushin.backend.schedule.dto.response.DailyScheduleResponse( + s.id, + s.title, + s.fatigue, + s.startDate, + s.endDate, + s.isAllDay + ) + FROM Schedule s + WHERE s.coupleId = :coupleId + AND :date BETWEEN FUNCTION('DATE', s.startDate) AND FUNCTION('DATE', s.endDate) + """ + ) + fun findByCoupleIdAndDate(coupleId: Long, date: LocalDate): List + + @Query( + """ + SELECT new gomushin.backend.schedule.dto.response.MainSchedulesResponse( + s.id, + s.title, + s.startDate, + s.endDate, + s.fatigue + ) + FROM Schedule s + WHERE s.coupleId = :coupleId + AND s.startDate <= :endDate + AND s.endDate >= :startDate + """ + ) + fun findByCoupleIdAndStartDateAndEndDateBetween( + coupleId: Long, + startDate: LocalDateTime, + endDate: LocalDateTime + ): List +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/service/CommentService.kt b/src/main/kotlin/gomushin/backend/schedule/domain/service/CommentService.kt new file mode 100644 index 00000000..dacb6bc4 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/service/CommentService.kt @@ -0,0 +1,75 @@ +package gomushin.backend.schedule.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.schedule.domain.entity.Comment +import gomushin.backend.schedule.domain.entity.Letter +import gomushin.backend.schedule.domain.repository.CommentRepository +import gomushin.backend.schedule.dto.request.UpsertCommentRequest +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class CommentService( + private val commentRepository: CommentRepository, +) { + @Transactional + fun upsert( + id: Long?, + letterId: Long, + authorId: Long, + nickname: String, + upsertCommentRequest: UpsertCommentRequest + ) { + id?.let { commentId -> + getById(commentId).let { + if (it.authorId != authorId) { + throw BadRequestException("sarangggun.comment.unauthorized") + } + it.content = upsertCommentRequest.content + } + } ?: save( + Comment.of( + letterId = letterId, + authorId = authorId, + nickname = nickname, + content = upsertCommentRequest.content, + ) + ) + } + + @Transactional(readOnly = true) + fun findAllByLetterId(id: Long): List { + return commentRepository.findAllByLetterId(id) + } + + @Transactional(readOnly = true) + fun getById(id: Long): Comment { + return findById(id) ?: throw BadRequestException("sarangggun.comment.not-found") + } + + @Transactional(readOnly = true) + fun findById(id: Long): Comment? { + return commentRepository.findByIdOrNull(id) + } + + @Transactional + fun save(comment: Comment): Comment { + return commentRepository.save(comment) + } + + @Transactional + fun delete(id: Long) { + commentRepository.deleteById(id) + } + + @Transactional + fun deleteAllByMemberId(memberId: Long) { + commentRepository.deleteAllByAuthorId(memberId) + } + + @Transactional + fun deleteAllByLetterId(letterId: Long) { + commentRepository.deleteAllByLetterId(letterId) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/service/LetterService.kt b/src/main/kotlin/gomushin/backend/schedule/domain/service/LetterService.kt new file mode 100644 index 00000000..27ae3d15 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/service/LetterService.kt @@ -0,0 +1,103 @@ +package gomushin.backend.schedule.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.schedule.domain.entity.Letter +import gomushin.backend.schedule.domain.entity.Schedule +import gomushin.backend.schedule.domain.repository.LetterRepository +import gomushin.backend.schedule.dto.request.UpsertLetterRequest +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class LetterService( + private val letterRepository: LetterRepository, +) { + @Transactional + fun upsert(authorId: Long, authorName: String, couple: Couple, upsertLetterRequest: UpsertLetterRequest): Letter { + return upsertLetterRequest.letterId?.let { letterId -> + getById(letterId).apply { + title = upsertLetterRequest.title + content = upsertLetterRequest.content + }.let { savedLetter -> + save(savedLetter) + } + } ?: save( + Letter.of( + coupleId = couple.id, + scheduleId = upsertLetterRequest.scheduleId, + authorId = authorId, + author = authorName, + title = upsertLetterRequest.title, + content = upsertLetterRequest.content, + ) + ) + } + + @Transactional(readOnly = true) + fun findByCoupleIdAndScheduleId(couple: Couple, scheduleId: Long): List { + return letterRepository.findByCoupleIdAndScheduleId(couple.id, scheduleId) + } + + @Transactional(readOnly = true) + fun getById(id: Long) = findById(id) ?: throw BadRequestException("sarangggun.letter.not-exist") + + @Transactional(readOnly = true) + fun findById(id: Long) = letterRepository.findByIdOrNull(id) + + @Transactional(readOnly = true) + fun findByCouple(couple: Couple) = letterRepository.findByCoupleId(couple.id) + + @Transactional(readOnly = true) + fun getByCoupleAndScheduleAndId( + couple: Couple, + schedule: Schedule, + letterId: Long, + ) = findByCoupleIdAndScheduleIdAndId(couple, schedule.id, letterId) + ?: throw BadRequestException("sarangggun.letter.not-exist") + + @Transactional(readOnly = true) + fun findByCoupleIdAndScheduleIdAndId(couple: Couple, scheduleId: Long, letterId: Long) = + letterRepository.findByCoupleIdAndScheduleIdAndId(couple.id, scheduleId, letterId) + + @Transactional(readOnly = true) + fun findByCoupleAndSchedule(couple: Couple, schedule: Schedule) = + letterRepository.findByCoupleIdAndScheduleId(couple.id, schedule.id) + + @Transactional + fun save(letter: Letter) = letterRepository.save(letter) + + @Transactional + fun delete(letterId: Long) { + letterRepository.deleteById(letterId) + } + + @Transactional + fun deleteAllByMemberId(memberId: Long) { + letterRepository.deleteAllByAuthorId(memberId) + } + + @Transactional(readOnly = true) + fun findAllByAuthorId(memberId: Long): List { + return letterRepository.findAllByAuthorId(memberId) + } + + @Transactional(readOnly = true) + fun findAllToCouple( + couple: Couple, + pageable: Pageable + ): Page { + return letterRepository.findAllToCouple( + couple.id, + pageable + ) + } + + @Transactional(readOnly = true) + fun findTop5ByCreatedDateDesc(couple: Couple): List { + return letterRepository.findTop5ByCoupleIdOrderByCreatedAtDesc(couple.id) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/service/PictureService.kt b/src/main/kotlin/gomushin/backend/schedule/domain/service/PictureService.kt new file mode 100644 index 00000000..cd177bd5 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/service/PictureService.kt @@ -0,0 +1,64 @@ +package gomushin.backend.schedule.domain.service + +import gomushin.backend.schedule.domain.entity.Letter +import gomushin.backend.schedule.domain.entity.Picture +import gomushin.backend.schedule.domain.repository.PictureRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +class PictureService( + private val pictureRepository: PictureRepository, +) { + @Transactional + fun upsert(letterId: Long, pictureUrls: List) { + deleteAllByLetterId(letterId) + val newPictures = pictureUrls.map { Picture(letterId = letterId, pictureUrl = it) } + saveAll(newPictures) + } + + @Transactional(readOnly = true) + fun findAll(urls: List): List { + return pictureRepository.findAllByPictureUrlIn(urls) + } + + @Transactional(readOnly = true) + fun findAllByLetterId(letterId: Long): List { + return pictureRepository.findAllByLetterId(letterId) + } + + @Transactional(readOnly = true) + fun findFirstByLetterId(letterId: Long): Picture? { + return pictureRepository.findFirstByLetterIdOrderByIdAsc(letterId) + } + + @Transactional(readOnly = true) + fun findAllByLetter(letter: Letter): List { + return pictureRepository.findAllByLetterId(letter.id) + } + + @Transactional + fun saveAll(pictures: List): List { + return pictureRepository.saveAll(pictures) + } + + @Transactional + fun deleteAllByLetterId(letterId: Long) { + pictureRepository.deleteAllByLetterId(letterId) + } + + @Transactional(readOnly = true) + fun findAllByLetterIds(letterIds: List): List { + return pictureRepository.findAllByLetterIdIn(letterIds) + } + + @Transactional + fun deleteAllByLetterIds(letterIds: List) { + pictureRepository.deleteAllByLetterIdIn(letterIds) + } + + @Transactional + fun deleteAll(pictures: List) { + pictureRepository.deleteAll(pictures) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/domain/service/ScheduleService.kt b/src/main/kotlin/gomushin/backend/schedule/domain/service/ScheduleService.kt new file mode 100644 index 00000000..dbfc1ce1 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/domain/service/ScheduleService.kt @@ -0,0 +1,78 @@ +package gomushin.backend.schedule.domain.service + +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.schedule.domain.entity.Schedule +import gomushin.backend.schedule.domain.repository.ScheduleRepository +import gomushin.backend.schedule.dto.request.UpsertScheduleRequest +import gomushin.backend.schedule.dto.response.DailyScheduleResponse +import gomushin.backend.schedule.dto.response.MainSchedulesResponse +import gomushin.backend.schedule.dto.response.MonthlySchedulesResponse +import org.springframework.data.repository.findByIdOrNull +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDate + +@Service +class ScheduleService( + private val scheduleRepository: ScheduleRepository, +) { + @Transactional(readOnly = true) + fun findByCoupleAndYearAndMonth(couple: Couple, year: Int, month: Int): List { + return scheduleRepository.findByCoupleIdAndYearAndMonth(couple.id, year, month) + } + + @Transactional(readOnly = true) + fun findByCoupleAndDateBetween( + couple: Couple, + startDate: LocalDate, + endDate: LocalDate + ): List { + return scheduleRepository.findByCoupleIdAndStartDateAndEndDateBetween( + couple.id, + startDate.atStartOfDay(), + endDate.atTime(23, 59, 59) + ) + } + + @Transactional(readOnly = true) + fun findByDate(couple: Couple, date: LocalDate): List { + return scheduleRepository.findByCoupleIdAndDate(couple.id, date) + } + + @Transactional + fun upsert(id: Long?, coupleId: Long, userId: Long, upsertScheduleRequest: UpsertScheduleRequest) { + id?.let { + getById(id).let { + it.startDate = upsertScheduleRequest.startDate + it.endDate = upsertScheduleRequest.endDate + it.title = upsertScheduleRequest.title + it.fatigue = upsertScheduleRequest.fatigue + it.isAllDay = upsertScheduleRequest.isAllDay + } + } ?: save(upsertScheduleRequest.toEntity(coupleId, userId)) + } + + @Transactional(readOnly = true) + fun getById(id: Long) = findById(id) ?: throw BadRequestException("sarangggun.schedule.not-exist-schedule") + + @Transactional(readOnly = true) + fun findById(id: Long) = scheduleRepository.findByIdOrNull(id) + + @Transactional + fun save(schedule: Schedule) = scheduleRepository.save(schedule) + + @Transactional + fun delete(coupleId: Long, userId: Long, scheduleId: Long) { + val schedule = getById(scheduleId) + if (schedule.coupleId != coupleId || schedule.userId != userId) { + throw BadRequestException("sarangggun.schedule.unauthorized") + } + scheduleRepository.deleteById(scheduleId) + } + + @Transactional + fun deleteAllByMemberId(memberId: Long) { + scheduleRepository.deleteAllByUserId(memberId) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/request/ReadLettersToMePaginationRequest.kt b/src/main/kotlin/gomushin/backend/schedule/dto/request/ReadLettersToMePaginationRequest.kt new file mode 100644 index 00000000..5a2260e6 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/request/ReadLettersToMePaginationRequest.kt @@ -0,0 +1,7 @@ +package gomushin.backend.schedule.dto.request + +data class ReadLettersToMePaginationRequest( + val key: Long = Long.MAX_VALUE, + val orderCreatedAt: String = "DESC", + val take: Long = 10L, +) diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertCommentRequest.kt b/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertCommentRequest.kt new file mode 100644 index 00000000..2e8e7c52 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertCommentRequest.kt @@ -0,0 +1,10 @@ +package gomushin.backend.schedule.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpsertCommentRequest( + @Schema(description = "댓글 ID(새로 생성 시 null, 업데이트 시 id)", example = "1") + val commentId: Long?, + @Schema(description = "내용", example = "훈련 힘내") + val content: String, +) diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertLetterRequest.kt b/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertLetterRequest.kt new file mode 100644 index 00000000..f7f9b68a --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertLetterRequest.kt @@ -0,0 +1,16 @@ +package gomushin.backend.schedule.dto.request + +import io.swagger.v3.oas.annotations.media.Schema + +data class UpsertLetterRequest( + @Schema(description = "편지 ID(새로 생성 시 null, 업데이트 시 id)", example = "1") + val letterId: Long? = null, + @Schema(description = "일정 ID", example = "1") + val scheduleId: Long, + @Schema(description = "편지 제목", example = "훈련 힘내") + val title: String, + @Schema(description = "편지 내용", example = "화이팅") + val content: String, + @Schema(description = "편지 사진 URL 목록", example = "[\"https://example.com/picture1.jpg\", \"https://example.com/picture2.jpg\"]") + val pictureUrls : List = emptyList(), +) diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertScheduleRequest.kt b/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertScheduleRequest.kt new file mode 100644 index 00000000..fbad5c1b --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/request/UpsertScheduleRequest.kt @@ -0,0 +1,30 @@ +package gomushin.backend.schedule.dto.request + +import gomushin.backend.schedule.domain.entity.Schedule +import io.swagger.v3.oas.annotations.media.Schema +import java.time.LocalDateTime + +data class UpsertScheduleRequest( + @Schema(description = "일정 ID(새로 생성 시 null, 업데이트 시 id)", example = "1") + val id: Long?, + @Schema(description = "일정 제목", example = "훈련") + val title: String, + @Schema(description = "일정 시작 시간", example = "2023-10-01T10:00:00") + val startDate: LocalDateTime, + @Schema(description = "일정 종료 시간", example = "2023-10-01T12:00:00") + val endDate: LocalDateTime, + @Schema(description = "피로도 (VERY_TIRED, TIRED, GOOD) ", example = "VERT_TIRED") + val fatigue: String, + @Schema(description = "하루 종일 여부", example = "false") + val isAllDay: Boolean = false, +) { + fun toEntity(coupleId: Long, userId: Long) = Schedule( + coupleId = coupleId, + userId = userId, + title = title, + startDate = startDate, + endDate = endDate, + fatigue = fatigue, + isAllDay = isAllDay + ) +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/CommentResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/CommentResponse.kt new file mode 100644 index 00000000..aadecea8 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/CommentResponse.kt @@ -0,0 +1,22 @@ +package gomushin.backend.schedule.dto.response + +import gomushin.backend.schedule.domain.entity.Comment +import java.time.LocalDateTime + +data class CommentResponse( + val id: Long, + val content: String, + val author: String, + val createdAt: LocalDateTime, +) { + companion object { + fun of(comment: Comment): CommentResponse { + return CommentResponse( + id = comment.id, + content = comment.content, + author = comment.nickname, + createdAt = comment.createdAt, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/DailyAnniversaryResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/DailyAnniversaryResponse.kt new file mode 100644 index 00000000..3c026f78 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/DailyAnniversaryResponse.kt @@ -0,0 +1,11 @@ +package gomushin.backend.schedule.dto.response + +import gomushin.backend.couple.domain.value.AnniversaryEmoji +import java.time.LocalDate + +data class DailyAnniversaryResponse( + val id: Long, + val title: String, + val emoji: AnniversaryEmoji, + val anniversaryDate: LocalDate, +) diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/DailyScheduleResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/DailyScheduleResponse.kt new file mode 100644 index 00000000..547ee9d2 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/DailyScheduleResponse.kt @@ -0,0 +1,12 @@ +package gomushin.backend.schedule.dto.response + +import java.time.LocalDateTime + +data class DailyScheduleResponse( + val id: Long, + val title: String, + val fatigue: String, + val startDate: LocalDateTime, + val endDate: LocalDateTime, + val isAllDay: Boolean +) diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/DailySchedulesAndAnniversariesResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/DailySchedulesAndAnniversariesResponse.kt new file mode 100644 index 00000000..f1d1cdb3 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/DailySchedulesAndAnniversariesResponse.kt @@ -0,0 +1,15 @@ +package gomushin.backend.schedule.dto.response + +data class DailySchedulesAndAnniversariesResponse( + val schedules: List, + val anniversaries: List, +) { + companion object { + fun of( + schedules: List, + anniversaries: List, + ): DailySchedulesAndAnniversariesResponse { + return DailySchedulesAndAnniversariesResponse(schedules, anniversaries) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterDetailResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterDetailResponse.kt new file mode 100644 index 00000000..e21a77ed --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterDetailResponse.kt @@ -0,0 +1,21 @@ +package gomushin.backend.schedule.dto.response + +data class LetterDetailResponse( + val letter: LetterResponse, + val pictures: List, + val comments: List, +) { + companion object { + fun of( + letter: LetterResponse, + pictures: List, + comments: List, + ): LetterDetailResponse { + return LetterDetailResponse( + letter = letter, + pictures = pictures, + comments = comments, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterPreviewResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterPreviewResponse.kt new file mode 100644 index 00000000..d3ec9de4 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterPreviewResponse.kt @@ -0,0 +1,46 @@ +package gomushin.backend.schedule.dto.response + +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.schedule.domain.entity.Letter +import gomushin.backend.schedule.domain.entity.Picture +import gomushin.backend.schedule.domain.entity.Schedule +import java.time.LocalDateTime + +data class LetterPreviewResponse( + val letterId: Long?, + val scheduleId: Long?, + val scheduleTitle: String?, + val title: String?, + val content: String?, + val pictureUrl: String?, + val isWrittenByMe : Boolean?, + val createdAt: LocalDateTime?, +) { + companion object { + private const val MAX_CONTENT_LENGTH = 30 + private const val PREVIEW_CONTENT_LENGTH = 27 + + fun of( + letter: Letter?, + schedule: Schedule?, + picture: Picture?, + member : Member?, + ): LetterPreviewResponse { + val previewContent = if (letter?.content != null && letter.content.length > MAX_CONTENT_LENGTH) { + letter.content.take(PREVIEW_CONTENT_LENGTH) + "..." + } else { + letter?.content + } + return LetterPreviewResponse( + letterId = letter?.id, + scheduleId = schedule?.id, + scheduleTitle = schedule?.title, + title = letter?.title, + content = previewContent, + pictureUrl = picture?.pictureUrl, + isWrittenByMe = member?.id == letter?.authorId, + createdAt = letter?.createdAt + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterResponse.kt new file mode 100644 index 00000000..f06a8b70 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/LetterResponse.kt @@ -0,0 +1,26 @@ +package gomushin.backend.schedule.dto.response + +import gomushin.backend.schedule.domain.entity.Letter +import java.time.LocalDateTime + +data class LetterResponse( + val id: Long, + val title: String, + val content: String, + val author: String, + val isWrittenByMe : Boolean, + val createdAt: LocalDateTime, +) { + companion object { + fun of(letter: Letter, memberId : Long): LetterResponse { + return LetterResponse( + id = letter.id, + title = letter.title, + content = letter.content, + author = letter.author, + isWrittenByMe = letter.authorId == memberId, + createdAt = letter.createdAt, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/MainAnniversariesResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/MainAnniversariesResponse.kt new file mode 100644 index 00000000..16433e13 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/MainAnniversariesResponse.kt @@ -0,0 +1,9 @@ +package gomushin.backend.schedule.dto.response + +import java.time.LocalDate + +data class MainAnniversariesResponse( + val id: Long, + val title: String, + val anniversaryDate: LocalDate, +) diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/MainLetterPreviewResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/MainLetterPreviewResponse.kt new file mode 100644 index 00000000..d30acb51 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/MainLetterPreviewResponse.kt @@ -0,0 +1,46 @@ +package gomushin.backend.schedule.dto.response + +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.schedule.domain.entity.Letter +import gomushin.backend.schedule.domain.entity.Picture +import gomushin.backend.schedule.domain.entity.Schedule +import java.time.LocalDateTime + +data class MainLetterPreviewResponse( + val letterId: Long?, + val title: String?, + val content: String?, + val pictureUrl: String?, + val schedule: String?, + val scheduleId : Long?, + val isWrittenByMe : Boolean?, + val createdAt: LocalDateTime?, +) { + companion object { + private const val MAX_CONTENT_LENGTH = 30 + private const val PREVIEW_CONTENT_LENGTH = 27 + + fun of( + letter: Letter?, + picture: Picture?, + schedule: Schedule?, + member : Member?, + ): MainLetterPreviewResponse { + val previewContent = if (letter?.content != null && letter.content.length > MAX_CONTENT_LENGTH) { + letter.content.take(PREVIEW_CONTENT_LENGTH) + "..." + } else { + letter?.content + } + return MainLetterPreviewResponse( + letterId = letter?.id, + title = letter?.title, + content = previewContent, + pictureUrl = picture?.pictureUrl, + schedule = schedule?.title, + scheduleId = schedule?.id, + isWrittenByMe = member?.id == letter?.authorId, + createdAt = letter?.createdAt + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/MainSchedulesAndAnniversariesResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/MainSchedulesAndAnniversariesResponse.kt new file mode 100644 index 00000000..91992e10 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/MainSchedulesAndAnniversariesResponse.kt @@ -0,0 +1,16 @@ +package gomushin.backend.schedule.dto.response + +data class MainSchedulesAndAnniversariesResponse( + val schedules: List, + val anniversaries: List, +) { + companion object { + fun of( + schedules: List, + anniversaries: List, + ): MainSchedulesAndAnniversariesResponse { + return MainSchedulesAndAnniversariesResponse(schedules, anniversaries) + } + } +} + diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/MainSchedulesResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/MainSchedulesResponse.kt new file mode 100644 index 00000000..f2ff9755 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/MainSchedulesResponse.kt @@ -0,0 +1,11 @@ +package gomushin.backend.schedule.dto.response + +import java.time.LocalDateTime + +data class MainSchedulesResponse( + val id: Long, + val title: String, + val startDate: LocalDateTime, + val endDate: LocalDateTime, + val fatigue: String, +) diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/MonthlySchedulesAndAnniversariesResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/MonthlySchedulesAndAnniversariesResponse.kt new file mode 100644 index 00000000..c21e2f42 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/MonthlySchedulesAndAnniversariesResponse.kt @@ -0,0 +1,17 @@ +package gomushin.backend.schedule.dto.response + +import gomushin.backend.couple.dto.response.MonthlyAnniversariesResponse + +data class MonthlySchedulesAndAnniversariesResponse( + val schedules: List, + val anniversaries: List, +) { + companion object { + fun of( + schedules: List, + anniversaries: List, + ): MonthlySchedulesAndAnniversariesResponse { + return MonthlySchedulesAndAnniversariesResponse(schedules, anniversaries) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/MonthlySchedulesResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/MonthlySchedulesResponse.kt new file mode 100644 index 00000000..a16a7bfd --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/MonthlySchedulesResponse.kt @@ -0,0 +1,11 @@ +package gomushin.backend.schedule.dto.response + +import java.time.LocalDateTime + +data class MonthlySchedulesResponse( + val id : Long, + val title: String, + val startDate: LocalDateTime, + val endDate: LocalDateTime, + val fatigue: String, +) diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/PictureResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/PictureResponse.kt new file mode 100644 index 00000000..81002414 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/PictureResponse.kt @@ -0,0 +1,20 @@ +package gomushin.backend.schedule.dto.response + +import gomushin.backend.schedule.domain.entity.Picture + +data class PictureResponse( + val id: Long, + val pictureUrl: String, + val letterId: Long, +) { + companion object { + fun of(picture: Picture): PictureResponse { + return PictureResponse( + id = picture.id, + pictureUrl = picture.pictureUrl, + letterId = picture.letterId, + ) + } + } +} + diff --git a/src/main/kotlin/gomushin/backend/schedule/dto/response/ScheduleDetailResponse.kt b/src/main/kotlin/gomushin/backend/schedule/dto/response/ScheduleDetailResponse.kt new file mode 100644 index 00000000..e2f2e921 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/dto/response/ScheduleDetailResponse.kt @@ -0,0 +1,28 @@ +package gomushin.backend.schedule.dto.response + +import gomushin.backend.schedule.domain.entity.Schedule +import java.time.LocalDateTime + +data class ScheduleDetailResponse( + val id: Long, + val title: String, + val fatigue: String, + val startDate: LocalDateTime, + val endDate: LocalDateTime, + val isAllDay: Boolean, + val letters: List, +) { + companion object { + fun of(schedule: Schedule, letters: List): ScheduleDetailResponse { + return ScheduleDetailResponse( + schedule.id, + schedule.title, + schedule.fatigue, + schedule.startDate, + schedule.endDate, + schedule.isAllDay, + letters, + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/facade/ReadLetterFacade.kt b/src/main/kotlin/gomushin/backend/schedule/facade/ReadLetterFacade.kt new file mode 100644 index 00000000..8af03208 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/facade/ReadLetterFacade.kt @@ -0,0 +1,104 @@ +package gomushin.backend.schedule.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.PageResponse +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.schedule.domain.service.CommentService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import gomushin.backend.schedule.dto.response.* +import org.springframework.beans.factory.annotation.Value +import org.springframework.data.domain.PageRequest +import org.springframework.stereotype.Component + +@Component +class ReadLetterFacade( + private val letterService: LetterService, + private val scheduleService: ScheduleService, + private val pictureService: PictureService, + private val commentService: CommentService, + private val memberService: MemberService, + @Value("\${server.url}") + private val baseUrl: String, +) { + + + fun getList( + customUserDetails: CustomUserDetails, + scheduleId: Long, + ): List { + val member = memberService.getById(customUserDetails.getId()) + val schedule = scheduleService.getById(scheduleId) + val letters = letterService.findByCoupleAndSchedule( + customUserDetails.getCouple(), + schedule, + ) + return letters.map { letter -> + val picture = pictureService.findFirstByLetterId(letter.id) + LetterPreviewResponse.of(letter, schedule, picture, member) + } + } + + fun get( + customUserDetails: CustomUserDetails, + scheduleId: Long, + letterId: Long, + ): LetterDetailResponse { + val memberId = customUserDetails.getId() + val schedule = scheduleService.getById(scheduleId) + val letter = letterService.getByCoupleAndScheduleAndId( + customUserDetails.getCouple(), + schedule, + letterId, + ) + + val letterResponse = LetterResponse.of(letter, memberId) + + val pictures = pictureService.findAllByLetter(letter) + val pictureResponses = pictures.map { picture -> + PictureResponse.of(picture) + } + + val comments = commentService.findAllByLetterId(letter.id) + val commentResponses = comments.map { comment -> + CommentResponse.of(comment) + } + + return LetterDetailResponse.of( + letter = letterResponse, + pictures = pictureResponses, + comments = commentResponses, + ) + } + + fun getLetterListToCouple( + customUserDetails: CustomUserDetails, + page: Int, + size: Int, + ): PageResponse { + val pageRequest = PageRequest.of(page, size) + val member = memberService.getById(customUserDetails.getId()) + val letters = letterService.findAllToCouple(customUserDetails.getCouple(), pageRequest) + + val letterPreviewResponses = letters.map { letter -> + val picture = letter.let { pictureService.findFirstByLetterId(it.id) } + val schedule = letter.let { scheduleService.getById(it.scheduleId) } + LetterPreviewResponse.of(letter, schedule, picture, member) + } + + return PageResponse.from(letterPreviewResponses) + } + + fun getLetterListMain( + customUserDetails: CustomUserDetails, + ): List { + val letters = letterService.findTop5ByCreatedDateDesc(customUserDetails.getCouple()) + val member = memberService.getById(customUserDetails.getId()) + return letters.map { letter -> + val picture = pictureService.findFirstByLetterId(letter.id) + val schedule = scheduleService.findById(letter.scheduleId) + MainLetterPreviewResponse.of(letter, picture, schedule, member) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/facade/ReadScheduleFacade.kt b/src/main/kotlin/gomushin/backend/schedule/facade/ReadScheduleFacade.kt new file mode 100644 index 00000000..29a2a318 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/facade/ReadScheduleFacade.kt @@ -0,0 +1,75 @@ +package gomushin.backend.schedule.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.couple.domain.service.AnniversaryService +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import gomushin.backend.schedule.dto.response.* +import kotlinx.coroutines.flow.merge +import org.springframework.stereotype.Component +import java.time.LocalDate + +@Component +class ReadScheduleFacade( + private val scheduleService: ScheduleService, + private val anniversaryService: AnniversaryService, + private val letterService: LetterService, + private val pictureService: PictureService, + private val memberService: MemberService, +) { + + companion object { + const val WEEK_DAYS = 6L + } + + fun getList( + customUserDetails: CustomUserDetails, + year: Int, + month: Int + ): MonthlySchedulesAndAnniversariesResponse { + val monthlySchedules = scheduleService.findByCoupleAndYearAndMonth(customUserDetails.getCouple(), year, month) + val monthlyAnniversaries = + anniversaryService.findByCoupleAndYearAndMonth(customUserDetails.getCouple(), year, month) + return MonthlySchedulesAndAnniversariesResponse.of(monthlySchedules, monthlyAnniversaries) + } + + fun get(customUserDetails: CustomUserDetails, date: LocalDate): DailySchedulesAndAnniversariesResponse { + val dailySchedules = scheduleService.findByDate(customUserDetails.getCouple(), date) + val dailyAnniversaries = anniversaryService.findByCoupleAndDate(customUserDetails.getCouple(), date) + return DailySchedulesAndAnniversariesResponse.of(dailySchedules, dailyAnniversaries) + } + + fun getDetail(customUserDetails: CustomUserDetails, scheduleId: Long): ScheduleDetailResponse { + val schedule = scheduleService.getById(scheduleId) + val letters = letterService.findByCoupleAndSchedule(customUserDetails.getCouple(), schedule) + val letterIds = letters.map { it.id } + val picturesByLetterId = pictureService.findAllByLetterIds(letterIds) + .associateBy { it.letterId } + val member = memberService.getById(customUserDetails.getId()) + val letterPreviews = letters.map { letter -> + LetterPreviewResponse.of(letter, schedule, picturesByLetterId[letter.id], member) + } + return ScheduleDetailResponse.of(schedule, letterPreviews) + } + + fun getListByWeek(customUserDetails: CustomUserDetails, baseDate : LocalDate = LocalDate.now()): MainSchedulesAndAnniversariesResponse { + val endDate = baseDate.plusDays(WEEK_DAYS) + val schedules = scheduleService.findByCoupleAndDateBetween( + customUserDetails.getCouple(), + baseDate, + endDate + ) + val anniversaries = anniversaryService.findByCoupleAndDateBetween( + customUserDetails.getCouple(), + baseDate, + endDate + ) + + return MainSchedulesAndAnniversariesResponse.of( + schedules, + anniversaries + ) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteCommentFacade.kt b/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteCommentFacade.kt new file mode 100644 index 00000000..a1224c88 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteCommentFacade.kt @@ -0,0 +1,43 @@ +package gomushin.backend.schedule.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.schedule.domain.service.CommentService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.dto.request.UpsertCommentRequest +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class UpsertAndDeleteCommentFacade( + private val commentService: CommentService, + private val letterService: LetterService, + private val memberService: MemberService, +) { + @Transactional + fun upsert(customUserDetails: CustomUserDetails, letterId: Long, upsertCommentRequest: UpsertCommentRequest) { + val member = memberService.getById(customUserDetails.getId()) + val letter = letterService.getById(letterId) + commentService.upsert( + id = upsertCommentRequest.commentId, + letterId = letter.id, + authorId = member.id, + nickname = member.nickname, + upsertCommentRequest = upsertCommentRequest + ) + } + + @Transactional + fun delete(customUserDetails: CustomUserDetails, letterId: Long, commentId: Long) { + val member = memberService.getById(customUserDetails.getId()) + val comment = commentService.getById(commentId) + if (comment.authorId != member.id) { + throw BadRequestException("sarangggun.comment.unauthorized") + } + if (comment.letterId != letterId) { + throw BadRequestException("sarangggun.comment.invalid-letter") + } + commentService.delete(commentId) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteLetterFacade.kt b/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteLetterFacade.kt new file mode 100644 index 00000000..16f7859c --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteLetterFacade.kt @@ -0,0 +1,90 @@ +package gomushin.backend.schedule.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.event.dto.S3DeleteEvent +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.core.service.S3Service +import gomushin.backend.schedule.domain.entity.Picture +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import gomushin.backend.schedule.dto.request.UpsertLetterRequest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile + +@Component +class UpsertAndDeleteLetterFacade( + private val letterService: LetterService, + private val s3Service: S3Service, + private val pictureService: PictureService, + private val scheduleService: ScheduleService, + private val applicationEventPublisher: ApplicationEventPublisher, +) { + // TODO: 이벤트 드리븐하게 수정 + @Transactional + fun upsert( + customUserDetails: CustomUserDetails, + upsertLetterRequest: UpsertLetterRequest, + pictures: List? + ) { + + val schedule = scheduleService.getById(upsertLetterRequest.scheduleId) + + if (schedule.coupleId != customUserDetails.getCouple().id) { + throw BadRequestException("sarangggun.letter.not-in-couple") + } + + val letter = letterService.upsert( + customUserDetails.getId(), + customUserDetails.username, + customUserDetails.getCouple(), + upsertLetterRequest + ) + + val existingPictures = pictureService.findAllByLetterId(letter.id) + val existingUrls = existingPictures.map { it.pictureUrl } + + val toDelete = existingPictures.filter { it.pictureUrl !in upsertLetterRequest.pictureUrls } + val pictureUrlsToDelete = toDelete.map { it.pictureUrl } + pictureService.deleteAll(toDelete) + + val uploadedUrls = pictures?.map { s3Service.uploadFile(it) } ?: emptyList() + uploadedUrls + .filter { it !in existingUrls } + .forEach { url -> + pictureService.saveAll(listOf(Picture(letterId = letter.id, pictureUrl = url))) + } + + if (pictureUrlsToDelete.isNotEmpty()) { + applicationEventPublisher.publishEvent(S3DeleteEvent(pictureUrlsToDelete)) + } + } + + @Transactional + fun delete( + customUserDetails: CustomUserDetails, + scheduleId: Long, + letterId: Long + ) { + val letter = letterService.getById(letterId) + + if (letter.authorId != customUserDetails.getId()) { + throw BadRequestException("sarangggun.letter.unauthorized") + } + + if (letter.scheduleId != scheduleId) { + throw BadRequestException("sarangggun.letter.invalid-schedule") + } + + pictureService.findAllByLetter(letter) + .takeIf { it.isNotEmpty() } + ?.forEach { picture -> + s3Service.deleteFile(picture.pictureUrl) + } + + letterService.delete(letterId) + pictureService.deleteAllByLetterId(letterId) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteScheduleFacade.kt b/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteScheduleFacade.kt new file mode 100644 index 00000000..5f1b442a --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/facade/UpsertAndDeleteScheduleFacade.kt @@ -0,0 +1,56 @@ +package gomushin.backend.schedule.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.event.dto.S3DeleteEvent +import gomushin.backend.schedule.domain.service.CommentService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import gomushin.backend.schedule.dto.request.UpsertScheduleRequest +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +@Component +class UpsertAndDeleteScheduleFacade( + private val scheduleService: ScheduleService, + private val letterService: LetterService, + private val commentService: CommentService, + private val pictureService: PictureService, + private val applicationEventPublisher: ApplicationEventPublisher, +) { + + fun upsert(customUserDetails: CustomUserDetails, upsertScheduleRequest: UpsertScheduleRequest) { + scheduleService.upsert( + upsertScheduleRequest.id, + customUserDetails.getCouple().id, + customUserDetails.getId(), + upsertScheduleRequest + ) + } + + @Transactional + fun delete(customUserDetails: CustomUserDetails, scheduleId: Long) { + val schedule = scheduleService.getById(scheduleId) + val pictureUrlsToDelete = mutableListOf() + letterService.findByCoupleAndSchedule(customUserDetails.getCouple(), schedule).forEach { letter -> + pictureService.findAllByLetter(letter) + .takeIf { it.isNotEmpty() } + ?.forEach { picture -> + pictureUrlsToDelete.add(picture.pictureUrl) + } + pictureService.deleteAllByLetterId(letter.id) + commentService.deleteAllByLetterId(letter.id) + letterService.delete(letter.id) + } + scheduleService.delete(customUserDetails.getCouple().id, customUserDetails.getId(), scheduleId) + + if (pictureUrlsToDelete.isNotEmpty()) { + applicationEventPublisher.publishEvent( + S3DeleteEvent( + pictureUrls = pictureUrlsToDelete + ) + ) + } + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/presentation/ApiPath.kt b/src/main/kotlin/gomushin/backend/schedule/presentation/ApiPath.kt new file mode 100644 index 00000000..e36623ad --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/presentation/ApiPath.kt @@ -0,0 +1,17 @@ +package gomushin.backend.schedule.presentation + +object ApiPath { + const val SCHEDULES = "/v1/schedules" + const val SCHEDULE = "/v1/schedules/{scheduleId}" + const val SCHEDULES_BY_DATE = "/v1/schedules/date" + const val SCHEDULES_BY_WEEK = "/v1/schedules/week" + const val SCHEDULE_DETAIL = "/v1/schedules/detail/{scheduleId}" + + const val LETTERS = "/v1/schedules/letters" + const val LETTER = "/v1/schedules/{scheduleId}/letters/{letterId}" + const val LETTERS_BY_SCHEDULE = "/v1/schedules/{scheduleId}" + const val LETTERS_MAIN = "/v1/schedules/letters/main" + + const val COMMENTS = "/v1/schedules/letters/{letterId}/comments" + const val COMMENT = "/v1/schedules/letters/{letterId}/comments/{commentId}" +} diff --git a/src/main/kotlin/gomushin/backend/schedule/presentation/ReadLetterController.kt b/src/main/kotlin/gomushin/backend/schedule/presentation/ReadLetterController.kt new file mode 100644 index 00000000..f50fd1d0 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/presentation/ReadLetterController.kt @@ -0,0 +1,66 @@ +package gomushin.backend.schedule.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.PageResponse +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.schedule.dto.response.LetterDetailResponse +import gomushin.backend.schedule.dto.response.LetterPreviewResponse +import gomushin.backend.schedule.dto.response.MainLetterPreviewResponse +import gomushin.backend.schedule.facade.ReadLetterFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +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.RequestParam +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "편지 조회", description = "ReadLetterController") +class ReadLetterController( + private val readLetterFacade: ReadLetterFacade +) { + + @GetMapping(ApiPath.LETTERS_BY_SCHEDULE) + @Operation(summary = "특정 일정의 편지 리스트 가져오기", description = "getLetterList") + fun getLetterList( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable scheduleId: Long, + ): ApiResponse> { + val letters = readLetterFacade.getList(customUserDetails, scheduleId) + return ApiResponse.success(letters) + } + + @GetMapping(ApiPath.LETTER) + @Operation(summary = "특정 편지 가져오기", description = "getLetter") + fun getLetter( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable scheduleId: Long, + @PathVariable letterId: Long, + ): ApiResponse { + val letter = readLetterFacade.get(customUserDetails, scheduleId, letterId) + return ApiResponse.success(letter) + } + + @GetMapping(ApiPath.LETTERS) + @Operation(summary = "커플 전체 편지 리스트 가져오기", description = "getLetterListToMe") + fun getLetterListToMe( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestParam(defaultValue = "1") page: Int, + @RequestParam(defaultValue = "10") size: Int, + ): PageResponse { + val safePage = if (page < 1) 0 else page - 1 + val letters = readLetterFacade.getLetterListToCouple(customUserDetails, safePage, size) + return letters + } + + @GetMapping(ApiPath.LETTERS_MAIN) + @Operation(summary = "메인화면 편지 리스트 가져오기", description = "getLetterListMain") + fun getLetterListMain( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + ): ApiResponse> { + val letters = readLetterFacade.getLetterListMain(customUserDetails) + return ApiResponse.success(letters) + } + +} diff --git a/src/main/kotlin/gomushin/backend/schedule/presentation/ReadScheduleController.kt b/src/main/kotlin/gomushin/backend/schedule/presentation/ReadScheduleController.kt new file mode 100644 index 00000000..be00f69d --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/presentation/ReadScheduleController.kt @@ -0,0 +1,64 @@ +package gomushin.backend.schedule.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.schedule.dto.response.DailySchedulesAndAnniversariesResponse +import gomushin.backend.schedule.dto.response.MainSchedulesAndAnniversariesResponse +import gomushin.backend.schedule.dto.response.MonthlySchedulesAndAnniversariesResponse +import gomushin.backend.schedule.dto.response.ScheduleDetailResponse +import gomushin.backend.schedule.facade.ReadScheduleFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +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.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDate + +@RestController +@Tag(name = "일정 조회", description = "ReadScheduleController") +class ReadScheduleController( + private val readScheduleFacade: ReadScheduleFacade +) { + + @GetMapping(ApiPath.SCHEDULES) + @Operation(summary = "특정 달의 일정 리스트 가져오기", description = "getScheduleList") + fun getScheduleList( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestParam year: Int, + @RequestParam month: Int, + ): ApiResponse { + val schedules = readScheduleFacade.getList(customUserDetails, year, month) + return ApiResponse.success(schedules) + } + + @GetMapping(ApiPath.SCHEDULES_BY_DATE) + @Operation(summary = "특정 날짜의 일정 가져오기", description = "getSchedule") + fun getSchedule( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestParam date: LocalDate, + ): ApiResponse { + val schedules = readScheduleFacade.get(customUserDetails, date) + return ApiResponse.success(schedules) + } + + @GetMapping(ApiPath.SCHEDULE_DETAIL) + @Operation(summary = "특정 스케쥴 상세조회", description = "getDetail") + fun getScheduleDetail( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable scheduleId: Long + ): ApiResponse { + val scheduleDetails = readScheduleFacade.getDetail(customUserDetails, scheduleId) + return ApiResponse.success(scheduleDetails) + } + + @GetMapping(ApiPath.SCHEDULES_BY_WEEK) + @Operation(summary = "메인 - 오늘 부터 일주일간의 일정 및 기념일 가져오기", description = "getScheduleByWeek") + fun getScheduleByWeek( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + ): ApiResponse { + val schedules = readScheduleFacade.getListByWeek(customUserDetails) + return ApiResponse.success(schedules) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteCommentController.kt b/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteCommentController.kt new file mode 100644 index 00000000..d249f496 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteCommentController.kt @@ -0,0 +1,47 @@ +package gomushin.backend.schedule.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.schedule.dto.request.UpsertCommentRequest +import gomushin.backend.schedule.facade.UpsertAndDeleteCommentFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.ResponseStatus +import org.springframework.web.bind.annotation.RestController + +@RestController +@Tag(name = "댓글 생성 , 수정 , 삭제", description = "UpsertAndDeleteCommentController") +class UpsertAndDeleteCommentController( + private val upsertAndDeleteCommentFacade: UpsertAndDeleteCommentFacade, +) { + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(ApiPath.COMMENTS) + @Operation(summary = "댓글 수정하거나 추가하기", description = "upsertComment") + fun upsertComment( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable letterId: Long, + @RequestBody upsertCommentRequest: UpsertCommentRequest, + ): ApiResponse { + upsertAndDeleteCommentFacade.upsert(customUserDetails, letterId, upsertCommentRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping(ApiPath.COMMENT) + @Operation(summary = "댓글 삭제하기", description = "deleteComment") + fun deleteComment( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable letterId: Long, + @PathVariable commentId: Long, + ): ApiResponse { + upsertAndDeleteCommentFacade.delete(customUserDetails, letterId, commentId) + return ApiResponse.success(true) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteLetterController.kt b/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteLetterController.kt new file mode 100644 index 00000000..46b5d69d --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteLetterController.kt @@ -0,0 +1,48 @@ +package gomushin.backend.schedule.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.core.infrastructure.filter.logging.LoggingFilter +import gomushin.backend.schedule.dto.request.UpsertLetterRequest +import gomushin.backend.schedule.facade.UpsertAndDeleteLetterFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.slf4j.LoggerFactory +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile + +@RestController +@Tag(name = "편지 생성 , 수정 , 삭제", description = "UpsertAndDeleteLetterController") +class UpsertAndDeleteLetterController( + private val upsertAndDeleteLetterFacade: UpsertAndDeleteLetterFacade, +) { + private val log = LoggerFactory.getLogger(UpsertAndDeleteLetterController::class.java) + @ResponseStatus(HttpStatus.CREATED) + @PostMapping( + ApiPath.LETTERS + ) + @Operation(summary = "편지 수정하거나 추가하기", description = "upsertLetter") + fun upsertLetter( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestPart upsertLetterRequest: UpsertLetterRequest, + @RequestPart("pictures", required = false) pictures: List?, + ): ApiResponse { + log.info("[REQUEST LOG] userId={}, URL={}, Method={}, Body={}", customUserDetails.getId(), ApiPath.LETTERS, "POST", upsertLetterRequest) + upsertAndDeleteLetterFacade.upsert(customUserDetails, upsertLetterRequest, pictures) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping(ApiPath.LETTER) + @Operation(summary = "편지 삭제하기", description = "deleteLetter") + fun deleteLetter( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable scheduleId: Long, + @PathVariable letterId: Long, + ): ApiResponse { + upsertAndDeleteLetterFacade.delete(customUserDetails, scheduleId, letterId) + return ApiResponse.success(true) + } +} diff --git a/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteScheduleController.kt b/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteScheduleController.kt new file mode 100644 index 00000000..73848766 --- /dev/null +++ b/src/main/kotlin/gomushin/backend/schedule/presentation/UpsertAndDeleteScheduleController.kt @@ -0,0 +1,40 @@ +package gomushin.backend.schedule.presentation + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.web.response.ApiResponse +import gomushin.backend.schedule.dto.request.UpsertScheduleRequest +import gomushin.backend.schedule.facade.UpsertAndDeleteScheduleFacade +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import org.springframework.http.HttpStatus +import org.springframework.security.core.annotation.AuthenticationPrincipal +import org.springframework.web.bind.annotation.* + +@RestController +@Tag(name = "일정 생성 , 수정 , 삭제", description = "UpsertAndDeleteScheduleController") +class UpsertAndDeleteScheduleController( + private val upsertAndDeleteScheduleFacade: UpsertAndDeleteScheduleFacade, +) { + + @ResponseStatus(HttpStatus.CREATED) + @PostMapping(ApiPath.SCHEDULES) + @Operation(summary = "일정 수정하거나 추가하기", description = "upsertSchedule") + fun upsertSchedule( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @RequestBody upsertScheduleRequest: UpsertScheduleRequest + ): ApiResponse { + upsertAndDeleteScheduleFacade.upsert(customUserDetails, upsertScheduleRequest) + return ApiResponse.success(true) + } + + @ResponseStatus(HttpStatus.OK) + @DeleteMapping(ApiPath.SCHEDULE) + @Operation(summary = "일정 삭제하기", description = "deleteSchedule") + fun deleteSchedule( + @AuthenticationPrincipal customUserDetails: CustomUserDetails, + @PathVariable scheduleId: Long + ): ApiResponse { + upsertAndDeleteScheduleFacade.delete(customUserDetails, scheduleId) + return ApiResponse.success(true) + } +} diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..624f2c4f --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,27 @@ + + + + + ${LOG_PATH}/application-%d{yyyy-MM-dd}.%i.log + 2 + 100MB + 1GB + + + %d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n + + + + + + + %d{yyyy-MM-dd HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n + + + + + + + + + \ No newline at end of file diff --git a/src/test/kotlin/gomushin/backend/couple/domain/service/AnniversaryServiceTest.kt b/src/test/kotlin/gomushin/backend/couple/domain/service/AnniversaryServiceTest.kt new file mode 100644 index 00000000..6e240cc1 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/couple/domain/service/AnniversaryServiceTest.kt @@ -0,0 +1,202 @@ +package gomushin.backend.couple.domain.service + +import gomushin.backend.couple.domain.entity.Anniversary +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.repository.AnniversaryRepository +import gomushin.backend.couple.dto.response.MonthlyAnniversariesResponse +import gomushin.backend.schedule.dto.response.DailyAnniversaryResponse +import gomushin.backend.schedule.dto.response.MainAnniversariesResponse +import io.mockk.* +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import java.time.LocalDate +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@ExtendWith(MockKExtension::class) +class AnniversaryServiceTest { + @MockK + lateinit var anniversaryRepository: AnniversaryRepository + + @InjectMockKs + lateinit var anniversaryService: AnniversaryService + + @DisplayName("findAnniversaries 는 Anniversary 엔티티를 Page 형태로 반환한다.") + @Test + fun findAnniversaries_success() { + // given + val couple = Couple(1L, 1L, 2L) + val pageRequest = PageRequest.of(0, 10) + val expectedPage = mockk>() + + every { + anniversaryRepository.findAnniversaries(couple.id, pageRequest) + } returns expectedPage + + // when + anniversaryService.findAnniversaries(couple, pageRequest) + + // then + verify(exactly = 1) { + anniversaryRepository.findAnniversaries(couple.id, pageRequest) + } + } + + @DisplayName("findByCoupleAndDateBetween 는 Anniversary 엔티티를 List 형태로 반환한다.") + @Test + fun findByCoupleAndDateBetween_success() { + // given + val couple = Couple(1L, 1L, 2L) + val startDate = LocalDate.of(2025, 1, 1) + val endDate = LocalDate.of(2025, 12, 31) + val expectedList = listOf() + + every { + anniversaryRepository.findByCoupleIdAndDateBetween(couple.id, startDate, endDate) + } returns expectedList + + // when + anniversaryService.findByCoupleAndDateBetween(couple, startDate, endDate) + + // then + verify(exactly = 1) { + anniversaryRepository.findByCoupleIdAndDateBetween(couple.id, startDate, endDate) + } + } + + @DisplayName("findByCoupleAndYearAndMonth 는 Anniversary 엔티티를 List 형태로 반환한다.") + @Test + fun findByCoupleAndYearAndMonth_success() { + // given + val couple = Couple(1L, 1L, 2L) + val year = 2025 + val month = 1 + val expectedList = listOf() + + every { + anniversaryRepository.findByCoupleIdAndYearAndMonth(couple.id, year, month) + } returns expectedList + + // when + anniversaryService.findByCoupleAndYearAndMonth(couple, year, month) + + // then + verify(exactly = 1) { + anniversaryRepository.findByCoupleIdAndYearAndMonth(couple.id, year, month) + } + } + + @DisplayName("findByCoupleAndDate 는 Anniversary 엔티티를 List 형태로 반환한다.") + @Test + fun findByCoupleAndDate_success() { + // given + val couple = Couple(1L, 1L, 2L) + val date = LocalDate.of(2025, 1, 1) + val expectedList = listOf() + + every { + anniversaryRepository.findByCoupleIdAndDate(couple.id, date) + } returns expectedList + + // when + anniversaryService.findByCoupleAndDate(couple, date) + + // then + verify(exactly = 1) { + anniversaryRepository.findByCoupleIdAndDate(couple.id, date) + } + } + + @DisplayName("saveAll 은 Anniversary 엔티티를 List 형태로 저장한다.") + @Test + fun saveAll_success() { + // given + val anniversaries = listOf() + val expectedList = listOf() + every { + anniversaryRepository.saveAll(anniversaries) + } returns expectedList + + // when + anniversaryService.saveAll(anniversaries) + + // then + verify(exactly = 1) { + anniversaryRepository.saveAll(anniversaries) + } + } + + @DisplayName("getUpcomingTop3Anniversaries 는 Anniversary 엔티티를 List 형태로 반환하고, 최대 3개를 반환한다.") + @Test + fun getUpcomingTop3Anniversaries_success() { + // given + val couple = Couple(1L, 1L, 2L) + val expectedList = listOf( + Anniversary(1L, couple.id, "Anniversary 1", LocalDate.of(2025, 1, 1), 1), + Anniversary(2L, couple.id, "Anniversary 2", LocalDate.of(2025, 2, 1), 1), + Anniversary(3L, couple.id, "Anniversary 3", LocalDate.of(2025, 3, 1), 1), + Anniversary(4L, couple.id, "Anniversary 4", LocalDate.of(2025, 3, 1), 1) + ) + + every { + anniversaryRepository.findTop3UpcomingAnniversaries(couple.id) + } returns expectedList.take(3) + + // when + val result = anniversaryService.getUpcomingTop3Anniversaries(couple) + + // then + assertTrue(result.size <= 3, "반환된 기념일의 개수는 최대 3개여야 합니다.") + } + + @DisplayName("getUpcomingTop3Anniversaries 는 Anniversary 엔티티를 List 형태로 반환하고, 시간 순으로 정렬한다.") + @Test + fun getUpcomingTop3Anniversaries_sortedByDate() { + // given + val couple = Couple(1L, 1L, 2L) + val expectedList = listOf( + Anniversary(1L, couple.id, "Anniversary 1", LocalDate.of(2025, 1, 1), 1), + Anniversary(2L, couple.id, "Anniversary 2", LocalDate.of(2025, 2, 1), 1), + Anniversary(3L, couple.id, "Anniversary 3", LocalDate.of(2025, 3, 1), 1), + Anniversary(4L, couple.id, "Anniversary 4", LocalDate.of(2025, 4, 1), 1), + ) + + every { + anniversaryRepository.findTop3UpcomingAnniversaries(couple.id) + } returns expectedList.take(3) + + // when + val result = anniversaryService.getUpcomingTop3Anniversaries(couple) + + // then + assertEquals(result, expectedList.take(3)) + assertEquals( + result.map { it.anniversaryDate }.sorted(), + result.map { it.anniversaryDate }, + "반환된 기념일은 시간 순으로 정렬되어야 합니다." + ) + } + + @DisplayName("deleteAllByCoupleIdAndAutoInsert는 anniversaryRepository의 deleteAllByCoupleIdAndAutoInsertTrue메서드 호출") + @Test + fun deleteAllByCoupleIdAndAutoInsert() { + //given + val couple = Couple(1L, 1L, 2L) + every { + anniversaryRepository.deleteAllByCoupleIdAndAutoInsertTrue(couple.id) + } just Runs + //when + anniversaryService.deleteAllByCoupleAndAutoInsert(couple) + //then + verify(exactly = 1) { + anniversaryRepository.deleteAllByCoupleIdAndAutoInsertTrue(couple.id) + } + } +} + diff --git a/src/test/kotlin/gomushin/backend/couple/domain/service/CoupleInfoServiceTest.kt b/src/test/kotlin/gomushin/backend/couple/domain/service/CoupleInfoServiceTest.kt new file mode 100644 index 00000000..7a7dd71c --- /dev/null +++ b/src/test/kotlin/gomushin/backend/couple/domain/service/CoupleInfoServiceTest.kt @@ -0,0 +1,432 @@ +package gomushin.backend.couple.domain.service + +import gomushin.backend.couple.domain.entity.Anniversary +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.repository.AnniversaryRepository +import gomushin.backend.couple.domain.repository.CoupleRepository +import gomushin.backend.couple.dto.request.UpdateMilitaryDateRequest +import gomushin.backend.couple.dto.request.UpdateRelationshipStartDateRequest +import gomushin.backend.couple.dto.response.DdayResponse +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.repository.MemberRepository +import gomushin.backend.member.domain.value.Emotion +import gomushin.backend.member.domain.value.Provider +import gomushin.backend.member.domain.value.Role +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.junit.jupiter.MockitoExtension +import java.time.LocalDate +import java.util.* +import kotlin.test.assertEquals +import org.mockito.kotlin.any + +@ExtendWith(MockitoExtension::class) +class CoupleInfoServiceTest { + @Mock + private lateinit var coupleRepository: CoupleRepository + @Mock + private lateinit var memberRepository: MemberRepository + @Mock + private lateinit var anniversaryRepository: AnniversaryRepository + @Mock + private lateinit var anniversaryCalculator: AnniversaryCalculator + + + @InjectMocks + private lateinit var coupleInfoService: CoupleInfoService + + @DisplayName("getGrade - 성공(invitorId가 주어졌을 떄)") + @Test + fun getGrade_success_1() { + // given + val coupleId = 1L + val invitorId = 1L + val couple = Couple( + id = coupleId, + invitorId = 1L, + inviteeId = 2L, + militaryStartDate = LocalDate.of(2021,5,24) + ) + + `when`(coupleRepository.findByMemberId(invitorId)).thenReturn(couple) + + // when + coupleInfoService.getGrade(invitorId) + } + + @DisplayName("getGrade - 성공(inviteeId가 주어졌을 떄)") + @Test + fun getGrade_success_2() { + // given + val coupleId = 1L + val inviteeId = 2L + val couple = Couple( + id = coupleId, + invitorId = 1L, + inviteeId = 2L, + militaryStartDate = LocalDate.of(2021,5,24) + ) + + `when`(coupleRepository.findByMemberId(inviteeId)).thenReturn(couple) + + + // when + coupleInfoService.getGrade(inviteeId) + } + +// @DisplayName("getGrade - 실패(couple이 아닐 때)") +// @Test +// fun getGrade_fail() { +// // given +// val invitorId = 1L +// +// `when`(coupleRepository.findByInvitorId(anyLong())).thenReturn(null) +// `when`(coupleRepository.findByInviteeId(anyLong())).thenReturn(null) +// +// // when & then +// val exception = assertThrows(BadRequestException::class.java) { +// coupleInfoService.getGrade(invitorId) +// } +// assert(exception.message == "saranggun.couple.not-connected") +// } + + @DisplayName("computeGrade - 성공") + @Test + fun computeGrade_success() { + // given + val gradeMilitaryStartDate = LocalDate.of(2021, 5, 24) + + val gradeOneToday = LocalDate.of(2021, 6, 1) + val gradeTwoToday = LocalDate.of(2021, 7, 1) + val gradeThreeToday = LocalDate.of(2022, 2, 1) + val gradeFourToday = LocalDate.of(2022,8,1) + + // when + val resultGradeOne = coupleInfoService.computeGrade(gradeMilitaryStartDate, gradeOneToday) + val resultGradeTwo = coupleInfoService.computeGrade(gradeMilitaryStartDate, gradeTwoToday) + val resultGradeThree = coupleInfoService.computeGrade(gradeMilitaryStartDate, gradeThreeToday) + val resultGradeFour = coupleInfoService.computeGrade(gradeMilitaryStartDate, gradeFourToday) + + // then + assert(resultGradeOne == 1) + assert(resultGradeTwo == 2) + assert(resultGradeThree == 3) + assert(resultGradeFour == 4) + } + + @DisplayName("checkCouple - 성공") + @Test + fun checkCouple_success() { + //given + val userId = 1L + val coupleUserId = 2L + val notCoupleUserId = 3L + val user = Member( + id = userId, + name="김영록", + nickname="김영록", + email="test@test.com", + profileImageUrl = "url", + birthDate= LocalDate.of(2001,3,27), + provider=Provider.KAKAO, + role= Role.MEMBER, + isCouple= true + ) + val coupleUser = Member( + id = coupleUserId, + name="김영록 여친", + nickname="김영록 여친", + email="test2@test.com", + profileImageUrl = "url2", + birthDate= LocalDate.of(2001,5,19), + provider=Provider.KAKAO, + role= Role.MEMBER, + isCouple= true + ) + val notCoupleUser = Member( + id = coupleUserId, + name="김영록 여친", + nickname="김영록 여친", + email="test2@test.com", + profileImageUrl = "url2", + birthDate= LocalDate.of(2001,5,19), + provider=Provider.KAKAO, + role= Role.MEMBER, + isCouple= false + ) + `when`(memberRepository.findById(userId)).thenReturn(Optional.of(user)) + `when`(memberRepository.findById(coupleUserId)).thenReturn(Optional.of(coupleUser)) + `when`(memberRepository.findById(notCoupleUserId)).thenReturn(Optional.of(notCoupleUser)) + + //when + val resultTrue1 = coupleInfoService.checkCouple(1L) + val resultTrue2 = coupleInfoService.checkCouple(2L) + val resultFalse = coupleInfoService.checkCouple(3L) + + //then + assertEquals(true, resultTrue1) + assertEquals(true, resultTrue2) + assertEquals(false, resultFalse) + } + + @DisplayName("computeDday - 성공") + @Test + fun computeDday_success(){ + //given + val today = LocalDate.of(2025, 4, 20) + val yesterday = LocalDate.of(2025, 4, 19) + val tomorrow = LocalDate.of(2025, 4, 21) + + //when + val plusDday = coupleInfoService.computeDday(yesterday, today) + val minusDday = coupleInfoService.computeDday(tomorrow, today) + + //then + assertEquals(-1, minusDday) + assertEquals(1, plusDday) + } + + @DisplayName("getDday - 성공") + @Test + fun getDday_success(){ + //given + val coupleId = 1L + val invitorId = 1L + val inviteeId = 2L + val today = LocalDate.now() + val couple = Couple( + id = coupleId, + invitorId = invitorId, + inviteeId = inviteeId, + militaryStartDate = LocalDate.of(2024,5,24), + militaryEndDate = LocalDate.of(2025, 11, 23), + relationshipStartDate = LocalDate.of(2024,4,24) + ) + val militaryStartDate = couple.militaryStartDate!! + val militaryEndDate = couple.militaryEndDate!! + val relationshipStartDate = couple.relationshipStartDate!! + val expectedResponse = DdayResponse.of( + coupleInfoService.computeDday(relationshipStartDate, today) + 1, + coupleInfoService.computeDday(militaryStartDate, today), + coupleInfoService.computeDday(militaryEndDate, today), + ) + + `when`(coupleRepository.findByMemberId(invitorId)).thenReturn(couple) + + //when + val response = coupleInfoService.getDday(invitorId) + + //then + assertEquals(expectedResponse.sinceMilitaryStart, response.sinceMilitaryStart) + assertEquals(expectedResponse.militaryEndLeft, response.militaryEndLeft) + assertEquals(expectedResponse.sinceLove, response.sinceLove) + } + + @DisplayName("nickName-성공") + @Test + fun nickName(){ + //given + val coupleId = 1L + val userId = 1L + val coupleUserId = 2L + val couple = Couple( + id = coupleId, + invitorId = coupleUserId, + inviteeId = userId, + ) + val user = Member( + id = 1L, + name="김영록", + nickname="김영록", + email="test@test.com", + profileImageUrl = "url", + birthDate= LocalDate.of(2001,3,27), + provider=Provider.KAKAO, + role= Role.MEMBER, + isCouple= true + ) + val coupleUser = Member( + id = 2L, + name="김영록 여친", + nickname="김영록 여친", + email="test2@test.com", + profileImageUrl = "url2", + birthDate= LocalDate.of(2001,5,19), + provider=Provider.KAKAO, + role= Role.MEMBER, + isCouple= true + ) + `when`(coupleRepository.findByMemberId(userId)).thenReturn(couple) + `when`(memberRepository.findById(userId)).thenReturn(Optional.of(user)) + `when`(memberRepository.findById(coupleUserId)).thenReturn(Optional.of(coupleUser)) + + //when + val nicknameResponse = coupleInfoService.getNickName(userId) + + //then + assertEquals("김영록 여친", nicknameResponse.coupleNickname) + assertEquals("김영록", nicknameResponse.userNickname) + } + + @DisplayName("statusMessage-성공") + @Test + fun statusMessage(){ + //given + val coupleId = 1L + val userId = 1L + val coupleUserId = 2L + val couple = Couple( + id = coupleId, + invitorId = coupleUserId, + inviteeId = userId, + ) + val coupleUser = Member( + id = 2L, + name="김영록 여친", + nickname="김영록 여친", + email="test2@test.com", + profileImageUrl = "url2", + birthDate= LocalDate.of(2001,5,19), + provider=Provider.KAKAO, + role= Role.MEMBER, + isCouple= true, + statusMessage = "기분이 좋아용" + ) + `when`(coupleRepository.findByMemberId(userId)).thenReturn(couple) + `when`(memberRepository.findById(coupleUserId)).thenReturn(Optional.of(coupleUser)) + + //when + val statusMessage = coupleInfoService.getStatusMessage(userId) + + //then + assertEquals("기분이 좋아용", statusMessage) + } + + @DisplayName("updateMilitaryDate - 성공") + @Test + fun updateMilitaryDate() { + //given + val coupleId = 1L + val userId = 1L + val coupleUserId = 2L + val couple = Couple( + id = coupleId, + invitorId = coupleUserId, + inviteeId = userId, + relationshipStartDate = LocalDate.of(2020,8,1), + militaryStartDate = LocalDate.of(2021, 5, 24), + militaryEndDate = LocalDate.of(2022,11,23) + ) + doNothing().`when`(anniversaryRepository).deleteAnniversariesWithTitleEndingAndPropertyZero(coupleId) + `when`(anniversaryCalculator.calculateInitAnniversaries( + any(), + any(), + any(), + any(), + any>() + )).thenReturn(emptyList()) + `when`(anniversaryRepository.saveAll(anyList())).thenReturn(emptyList()) + val updateMilitaryDateRequest = UpdateMilitaryDateRequest( + LocalDate.of(2022, 5, 24), + LocalDate.of(2023,11,23) + ) + //when + val result = coupleInfoService.updateMilitaryDate(couple, updateMilitaryDateRequest) + + //then + verify(anniversaryRepository).deleteAnniversariesWithTitleEndingAndPropertyZero(coupleId) + verify(anniversaryCalculator).calculateInitAnniversaries( + any(), + any(), + any(), + any(), + any>() + ) + verify(anniversaryRepository).saveAll(anyList()) + assertEquals(couple.militaryStartDate, updateMilitaryDateRequest.militaryStartDate) + assertEquals(couple.militaryEndDate, updateMilitaryDateRequest.militaryEndDate) + } + + @DisplayName("updateRelationshipStartDate - 성공") + @Test + fun updateRelationshipStartDate() { + //given + val coupleId = 1L + val userId = 1L + val coupleUserId = 2L + val couple = Couple( + id = coupleId, + invitorId = coupleUserId, + inviteeId = userId, + relationshipStartDate = LocalDate.of(2020,8,1), + militaryStartDate = LocalDate.of(2021, 5, 24), + militaryEndDate = LocalDate.of(2022,11,23) + ) + doNothing().`when`(anniversaryRepository).deleteAnniversariesWithTitleEndingAndPropertyZero(coupleId) + `when`(anniversaryCalculator.calculateInitAnniversaries( + any(), + any(), + any(), + any(), + any>() + )).thenReturn(emptyList()) + `when`(anniversaryRepository.saveAll(anyList())).thenReturn(emptyList()) + val updateRelationshipStartDateRequest = UpdateRelationshipStartDateRequest( + LocalDate.of(2020, 7, 24), + ) + + //when + val result = coupleInfoService.updateRelationshipStartDate(couple, updateRelationshipStartDateRequest) + + //then + verify(anniversaryRepository).deleteAnniversariesWithTitleEndingAndPropertyZero(coupleId) + verify(anniversaryCalculator).calculateInitAnniversaries( + any(), + any(), + any(), + any(), + any>() + ) + verify(anniversaryRepository).saveAll(anyList()) + assertEquals(couple.relationshipStartDate, updateRelationshipStartDateRequest.relationshipStartDate) + } + + @DisplayName("getCoupleEmotion - 성공") + @Test + fun getCoupleEmotion(){ + //given + val coupleId = 1L + val userId = 1L + val coupleUserId = 2L + val couple = Couple( + id = coupleId, + invitorId = coupleUserId, + inviteeId = userId, + ) + val coupleUser = Member( + id = 2L, + name="김영록 여친", + nickname="김영록 여친", + email="test2@test.com", + profileImageUrl = "url2", + birthDate= LocalDate.of(2001,5,19), + provider=Provider.KAKAO, + role= Role.MEMBER, + isCouple= true, + statusMessage = "기분이 좋아용", + emotion = Emotion.HAPPY + ) + `when`(coupleRepository.findByMemberId(userId)).thenReturn(couple) + `when`(memberRepository.findById(coupleUserId)).thenReturn(Optional.of(coupleUser)) + + //when + val emotion = coupleInfoService.getCoupleEmotion(userId) + + //then + assertEquals(coupleUser.emotion, emotion) + } +} \ No newline at end of file diff --git a/src/test/kotlin/gomushin/backend/couple/domain/service/CoupleServiceTest.kt b/src/test/kotlin/gomushin/backend/couple/domain/service/CoupleServiceTest.kt new file mode 100644 index 00000000..9b7cdf43 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/couple/domain/service/CoupleServiceTest.kt @@ -0,0 +1,85 @@ +package gomushin.backend.couple.domain.service + +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.repository.CoupleRepository +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import java.util.* + +@ExtendWith(MockitoExtension::class) +class CoupleServiceTest { + + @Mock + private lateinit var coupleRepository: CoupleRepository + + @InjectMocks + private lateinit var coupleService: CoupleService + + @DisplayName("getById - 성공") + @Test + fun getById_success() { + // given + val coupleId = 1L + val couple = Couple( + id = coupleId, + invitorId = 1L, + inviteeId = 2L, + ) + + `when`(coupleRepository.findById(coupleId)).thenReturn(Optional.of(couple)) + + // when + val result = coupleService.getById(coupleId) + + // then + assert(result.id == coupleId) + verify(coupleRepository).findById(coupleId) + } + + @DisplayName("findById - 성공") + @Test + fun findById_success() { + // given + val coupleId = 1L + val couple = Couple( + id = coupleId, + invitorId = 1L, + inviteeId = 2L, + ) + + `when`(coupleRepository.findById(coupleId)).thenReturn(Optional.of(couple)) + + // when + val result = coupleService.findById(coupleId) + + // then + assert(result?.id == coupleId) + verify(coupleRepository).findById(coupleId) + } + + @DisplayName("save - 성공") + @Test + fun save_success() { + // given + val couple = Couple( + id = 1L, + invitorId = 1L, + inviteeId = 2L, + ) + + `when`(coupleRepository.save(couple)).thenReturn(couple) + + // when + val result = coupleService.save(couple) + + // then + assert(result.id == couple.id) + verify(coupleRepository).save(couple) + } +} diff --git a/src/test/kotlin/gomushin/backend/couple/facade/AnniversaryFacadeTest.kt b/src/test/kotlin/gomushin/backend/couple/facade/AnniversaryFacadeTest.kt new file mode 100644 index 00000000..7a3de726 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/couple/facade/AnniversaryFacadeTest.kt @@ -0,0 +1,87 @@ +package gomushin.backend.couple.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.couple.domain.entity.Anniversary +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.service.AnniversaryService +import gomushin.backend.schedule.dto.response.MainAnniversariesResponse +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.data.domain.PageImpl +import org.springframework.data.domain.PageRequest +import java.time.LocalDate + +@ExtendWith(MockKExtension::class) +class AnniversaryFacadeTest { + @MockK + lateinit var anniversaryService: AnniversaryService + + @InjectMockKs + lateinit var anniversaryFacade: AnniversaryFacade + + @DisplayName("getAnniversaryListMain 은 AnniversaryService 의 getUpcomingTop3Anniversaries 를 호출한다.") + @Test + fun getAnniversaryListMain_success() { + // given + val customUserDetails = mockk() + val couple = mockk() + val anniversaryList = listOf() + val anniversaryResponseList = listOf() + + every { + customUserDetails.getCouple() + } returns couple + + every { + anniversaryService.getUpcomingTop3Anniversaries(couple) + } returns anniversaryList + + // when + val result = anniversaryFacade.getAnniversaryListMain(customUserDetails) + + // then + verify(exactly = 1) { + anniversaryService.getUpcomingTop3Anniversaries(couple) + } + } + + @DisplayName("getAnniversaryList 은 AnniversaryService 의 findAnniversaries 를 호출한다.") + @Test + fun getAnniversaryList_success() { + // given + val customUserDetails = mockk() + val couple = mockk() + val page = 0 + val size = 10 + val pageRequest = PageRequest.of(page, size) + + val content = listOf( + Anniversary(1L, 1L, "Anniversary 1", LocalDate.of(2023, 10, 1), 1), + Anniversary(2L, 2L, "Anniversary 2", LocalDate.of(2023, 10, 2), 1) + ) + val anniversaries = PageImpl(content) + + every { customUserDetails.getCouple() } returns couple + every { + anniversaryService.findAnniversaries(couple, pageRequest) + } returns anniversaries + + // when + val result = anniversaryFacade.getAnniversaryList(customUserDetails, page, size) + + // then + verify(exactly = 1) { + anniversaryService.findAnniversaries(couple, pageRequest) + } + assertThat(result.data).hasSize(2) + assertThat(result.totalPages).isEqualTo(1) + } +} diff --git a/src/test/kotlin/gomushin/backend/couple/facade/CoupleFacadeTest.kt b/src/test/kotlin/gomushin/backend/couple/facade/CoupleFacadeTest.kt new file mode 100644 index 00000000..92a6719f --- /dev/null +++ b/src/test/kotlin/gomushin/backend/couple/facade/CoupleFacadeTest.kt @@ -0,0 +1,226 @@ +package gomushin.backend.couple.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.service.* +import gomushin.backend.couple.domain.value.AnniversaryEmoji +import gomushin.backend.couple.dto.request.GenerateAnniversaryRequest +import gomushin.backend.couple.dto.request.UpdateMilitaryDateRequest +import gomushin.backend.couple.dto.request.UpdateRelationshipStartDateRequest +import gomushin.backend.couple.dto.response.DdayResponse +import gomushin.backend.couple.dto.response.NicknameResponse +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.member.domain.value.Emotion +import gomushin.backend.member.domain.value.Provider +import gomushin.backend.member.domain.value.Role +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import java.time.LocalDate +import kotlin.test.assertEquals + +@ExtendWith(MockitoExtension::class) +class CoupleFacadeTest { + @Mock + private lateinit var coupleInfoService: CoupleInfoService + + @Mock + private lateinit var coupleConnectService: CoupleConnectService + + @Mock + private lateinit var anniversaryService: AnniversaryService + + @Mock + private lateinit var coupleService: CoupleService + + @Mock + private lateinit var memberService: MemberService + + @Mock + private lateinit var anniversaryCalculator: AnniversaryCalculator + + @InjectMocks + private lateinit var coupleFacade: CoupleFacade + + private lateinit var customUserDetails: CustomUserDetails + private lateinit var member1: Member + private lateinit var member2: Member + private lateinit var couple : Couple + + @BeforeEach + fun setUp(){ + member1 = Member( + id = 1L, + name = "곰신", + nickname = "곰신닉네임", + email = "test1@test.com", + birthDate = LocalDate.of(2001, 3, 1), + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.MEMBER, + ) + + member2 = Member( + id = 2L, + name = "꽃신", + nickname = "꽃신닉네임", + email = "test2@test.com", + birthDate = LocalDate.of(2001, 4, 1), + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.MEMBER, + ) + + couple = Couple( + id = 1L, + invitorId = 1L, + inviteeId = 2L, + militaryStartDate = LocalDate.of(2021,5,24) + ) + + customUserDetails = Mockito.mock(CustomUserDetails::class.java) + + } + + @DisplayName("grade 조회") + @Test + fun getGradeInfo(){ + `when`(customUserDetails.getId()).thenReturn(1L) + `when`(coupleInfoService.getGrade(customUserDetails.getId())).thenReturn(1) + val result = coupleFacade.getGradeInfo(customUserDetails) + verify(coupleInfoService).getGrade(1L) + assertEquals(1, result.grade) + } + + @DisplayName("커플 연동 여부 조회") + @Test + fun checkConnect(){ + `when`(customUserDetails.getId()).thenReturn(1L) + `when`(coupleInfoService.checkCouple(customUserDetails.getId())).thenReturn(true) + val result = coupleFacade.checkConnect(customUserDetails) + verify(coupleInfoService).checkCouple(1L) + assertEquals(true, result) + } + + @DisplayName("디데이 조회 - 정상응답") + @Test + fun getDday(){ + val expectedResponse = DdayResponse.of(100, 200,-345) + `when`(customUserDetails.getId()).thenReturn(1L) + `when`(coupleInfoService.getDday(customUserDetails.getId())).thenReturn(expectedResponse) + val result = coupleFacade.getDday(customUserDetails) + verify(coupleInfoService).getDday(1L) + assertEquals(expectedResponse.sinceLove, result.sinceLove) + assertEquals(expectedResponse.sinceMilitaryStart, result.sinceMilitaryStart) + assertEquals(expectedResponse.militaryEndLeft, result.militaryEndLeft) + } + + @DisplayName("디데이 조회 - 날짜 정보 안 들어갔을 때") + @Test + fun getDdayNull(){ + val expectedResponse = DdayResponse.of(null, null,null) + `when`(customUserDetails.getId()).thenReturn(1L) + `when`(coupleInfoService.getDday(customUserDetails.getId())).thenReturn(expectedResponse) + val result = coupleFacade.getDday(customUserDetails) + verify(coupleInfoService).getDday(1L) + assertEquals(null, result.sinceLove) + assertEquals(null, result.sinceMilitaryStart) + assertEquals(null, result.militaryEndLeft) + } + + @DisplayName("닉네임 조회 - 정상응답") + @Test + fun nickName(){ + `when`(customUserDetails.getId()).thenReturn(1L) + `when`(coupleInfoService.getNickName(customUserDetails.getId())).thenReturn(NicknameResponse("김영록", "김영록 여친")) + val result = coupleFacade.nickName(customUserDetails) + verify(coupleInfoService).getNickName(1L) + assertEquals("김영록", result.userNickname) + assertEquals("김영록 여친", result.coupleNickname) + } + + @DisplayName("상태 메시지 조회 - 정상응답") + @Test + fun statusMessage(){ + `when`(customUserDetails.getId()).thenReturn(1L) + `when`(coupleInfoService.getStatusMessage(customUserDetails.getId())).thenReturn("기분이 좋아용") + val result = coupleFacade.statusMessage(customUserDetails) + verify(coupleInfoService).getStatusMessage(1L) + assertEquals("기분이 좋아용", result.statusMessage) + } + + @DisplayName("입대일, 전역일 수정 - 정상응답") + @Test + fun updateMilitaryDate() { + `when`(customUserDetails.getId()).thenReturn(1L) + val updateMilitaryDateRequest = UpdateMilitaryDateRequest( + LocalDate.of(2022, 5, 24), + LocalDate.of(2023,11,23) + ) + val result = coupleFacade.updateMilitaryDate(customUserDetails, updateMilitaryDateRequest) + verify(coupleInfoService).updateMilitaryDate(customUserDetails.getCouple(), updateMilitaryDateRequest) + verify(anniversaryService).deleteAllByCoupleAndAutoInsert(customUserDetails.getCouple()) + } + + @DisplayName("만난날 수정 - 정상응답") + @Test + fun updateRelationshipStartDate() { + `when`(customUserDetails.getId()).thenReturn(1L) + val updateRelationshipStartDateRequest = UpdateRelationshipStartDateRequest( + LocalDate.of(2022, 5, 24), + ) + val result = coupleFacade.updateRelationshipStartDate(customUserDetails, updateRelationshipStartDateRequest) + verify(coupleInfoService).updateRelationshipStartDate(customUserDetails.getCouple(), updateRelationshipStartDateRequest) + } + + @DisplayName("이모지 조회 - 정상응답") + @Test + fun getEmotion() { + `when`(customUserDetails.getId()).thenReturn(1L) + `when`(coupleInfoService.getCoupleEmotion(customUserDetails.getId())).thenReturn(Emotion.HAPPY) + val result = coupleFacade.getCoupleEmotion(customUserDetails) + verify(coupleInfoService).getCoupleEmotion(1L) + assertEquals(Emotion.HAPPY.name, result.emotion) + } + + @DisplayName("기념일 생성 - 정상응답") + @Test + fun generateAnniversary() { + //given + val generateAnniversaryRequest = GenerateAnniversaryRequest( + "전역일", + AnniversaryEmoji.CAKE, + LocalDate.of(2025, 5, 1) + ) + `when`(customUserDetails.getCouple()).thenReturn(couple) +// `when`(anniversaryService.generateAnniversary(customUserDetails.getCouple(),generateAnniversaryRequest)).thenReturn( +// Mockito.mock(Unit::class.java) +// ) + //when + coupleFacade.generateAnniversary(customUserDetails, generateAnniversaryRequest) + //then + verify(anniversaryService).generateAnniversary(couple, generateAnniversaryRequest) + } + + @DisplayName("생년월일 조회 - 정상응답") + @Test + fun getCoupleBirthDay() { + //given + `when`(memberService.getById(customUserDetails.getId())).thenReturn(member1) + `when`(coupleInfoService.findCoupleMember(customUserDetails.getId())).thenReturn(member2) + //when + val result = coupleFacade.getCoupleBirthDay(customUserDetails) + //then + verify(coupleInfoService).findCoupleMember(customUserDetails.getId()) + assertEquals(result.myBirthDay, member1.birthDate) + assertEquals(result.partnerBirthday, member2.birthDate) + } +} diff --git a/src/test/kotlin/gomushin/backend/member/domain/service/MemberServiceTest.kt b/src/test/kotlin/gomushin/backend/member/domain/service/MemberServiceTest.kt new file mode 100644 index 00000000..51e31c1b --- /dev/null +++ b/src/test/kotlin/gomushin/backend/member/domain/service/MemberServiceTest.kt @@ -0,0 +1,130 @@ +package gomushin.backend.member.domain.service + +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.repository.MemberRepository +import gomushin.backend.member.domain.value.Provider +import gomushin.backend.member.domain.value.Role +import gomushin.backend.member.dto.request.UpdateMyBirthdayRequest +import gomushin.backend.member.dto.request.UpdateMyEmotionAndStatusMessageRequest +import gomushin.backend.member.dto.request.UpdateMyNickNameRequest +import gomushin.backend.member.domain.value.Emotion +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import java.time.LocalDate +import java.util.* +import kotlin.test.assertEquals + +@ExtendWith(MockitoExtension::class) +class MemberServiceTest { + + @Mock + private lateinit var memberRepository: MemberRepository + + @InjectMocks + private lateinit var memberService: MemberService + + @DisplayName("내 정보 조회 - [GUEST]") + @Test + fun getGuestInfo_success() { + // given + val memberId = 1L + val expectedMember = Member( + id = 1L, + name = "테스트", + nickname = "테스트 닉네임", + email = "test@test.com", + birthDate = null, + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.GUEST, + ) + + // when + `when`(memberRepository.findById(memberId)).thenReturn(Optional.of(expectedMember)) + val result = memberService.getById(memberId) + + // then + assertEquals(expectedMember, result) + } + + @DisplayName("이모지 및 상태 메시지 업데이트 - 성공") + @Test + fun updateMyEmotionAndStatusMessage() { + // given + val memberId = 1L + val expectedMember = Member( + id = 1L, + name = "테스트", + nickname = "테스트 닉네임", + email = "test@test.com", + birthDate = null, + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.GUEST, + emotion = Emotion.COMMON, + statusMessage = "상태 변경전" + ) + val updateMyEmotionAndStatusMessageRequest = UpdateMyEmotionAndStatusMessageRequest(Emotion.SAD, "상태 변경후") + //when + `when`(memberRepository.findById(memberId)).thenReturn(Optional.of(expectedMember)) + val result = memberService.updateMyEmotionAndStatusMessage(memberId, updateMyEmotionAndStatusMessageRequest) + //then + assertEquals(expectedMember.emotion, updateMyEmotionAndStatusMessageRequest.emotion) + assertEquals(expectedMember.statusMessage, updateMyEmotionAndStatusMessageRequest.statusMessage) + } + + @DisplayName("닉네임 수정 - 성공") + @Test + fun updateMyNickname() { + // given + val memberId = 1L + val expectedMember = Member( + id = 1L, + name = "테스트", + nickname = "테스트 닉네임", + email = "test@test.com", + birthDate = null, + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.GUEST, + emotion = Emotion.COMMON, + statusMessage = "상태 변경전" + ) + val updateMyNickNameRequest = UpdateMyNickNameRequest("테스트 닉네임 수정") + //when + `when`(memberRepository.findById(memberId)).thenReturn(Optional.of(expectedMember)) + val result = memberService.updateMyNickname(memberId, updateMyNickNameRequest) + //then + assertEquals(expectedMember.nickname, updateMyNickNameRequest.nickname) + } + + @DisplayName("생년월일 수정 - 성공") + @Test + fun updateMyBirthDate() { + // given + val memberId = 1L + val expectedMember = Member( + id = 1L, + name = "테스트", + nickname = "테스트 닉네임", + email = "test@test.com", + birthDate = LocalDate.of(2001, 3, 27), + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.GUEST, + emotion = Emotion.COMMON, + statusMessage = "상태 변경전", + ) + val updateMyBirthdayRequest = UpdateMyBirthdayRequest(LocalDate.of(2001, 3, 30)) + //when + `when`(memberRepository.findById(memberId)).thenReturn(Optional.of(expectedMember)) + val result = memberService.updateMyBirthDate(memberId, updateMyBirthdayRequest) + //then + assertEquals(expectedMember.birthDate, updateMyBirthdayRequest.birthDate) + } +} diff --git a/src/test/kotlin/gomushin/backend/member/domain/service/NotificationServiceTest.kt b/src/test/kotlin/gomushin/backend/member/domain/service/NotificationServiceTest.kt new file mode 100644 index 00000000..b5408eeb --- /dev/null +++ b/src/test/kotlin/gomushin/backend/member/domain/service/NotificationServiceTest.kt @@ -0,0 +1,44 @@ +package gomushin.backend.member.domain.service + +import gomushin.backend.member.domain.entity.Notification +import gomushin.backend.member.domain.repository.NotificationRepository +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.ArgumentMatchers.any +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import kotlin.test.assertEquals + +@ExtendWith(MockitoExtension::class) +class NotificationServiceTest { + + @Mock + private lateinit var notificationRepository: NotificationRepository + + @InjectMocks + private lateinit var notificationService: NotificationService + + @DisplayName("알림 초기화 성공 케이스") + @Test + fun initNotification_success() { + // given + val memberId = 1L + val isNotification = true + val notification = Notification.create(memberId).apply { + dday = true + partnerStatus = true + } + + // when + `when`(notificationRepository.save(any())).thenReturn(notification) + notificationService.initNotification(memberId, isNotification) + + // then + assertEquals(notification.dday, true) + assertEquals(notification.partnerStatus, true) + + } +} diff --git a/src/test/kotlin/gomushin/backend/member/domain/service/OnboardingServiceTest.kt b/src/test/kotlin/gomushin/backend/member/domain/service/OnboardingServiceTest.kt new file mode 100644 index 00000000..67c947f5 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/member/domain/service/OnboardingServiceTest.kt @@ -0,0 +1,61 @@ +package gomushin.backend.member.domain.service + +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.repository.MemberRepository +import gomushin.backend.member.domain.value.Provider +import gomushin.backend.member.domain.value.Role +import gomushin.backend.member.dto.request.OnboardingRequest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import java.time.LocalDate +import java.util.* +import kotlin.test.Test + +@ExtendWith(MockitoExtension::class) +class OnboardingServiceTest { + @Mock + private lateinit var memberRepository: MemberRepository + + private val onboardingService: OnboardingService by lazy { + OnboardingService(memberRepository) + } + + @Test + fun `onboarding 성공 케이스`() { + // given + val memberId = 1L + val existingMember = Member( + id = memberId, + name = "테스트", + nickname = "원래 닉네임", + email = "test@example.com", + birthDate = LocalDate.of(1990, 1, 1), + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.GUEST, + ) + + val onboardingRequest = OnboardingRequest( + nickname = "새로운 닉네임", + birthDate = LocalDate.of(2000, 1, 1), + fcmToken = "fcmToken", + isNotification = false, + ) + + `when`(memberRepository.findById(memberId)).thenReturn(Optional.of(existingMember)) + + // when + onboardingService.onboarding(memberId, onboardingRequest) + + // then + assertEquals("새로운 닉네임", existingMember.nickname) + assertEquals(LocalDate.of(2000, 1, 1), existingMember.birthDate) + assertEquals(Role.MEMBER, existingMember.role) + + verify(memberRepository).findById(memberId) + } +} diff --git a/src/test/kotlin/gomushin/backend/member/facade/LeaveFacadeTest.kt b/src/test/kotlin/gomushin/backend/member/facade/LeaveFacadeTest.kt new file mode 100644 index 00000000..9a5ba573 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/member/facade/LeaveFacadeTest.kt @@ -0,0 +1,162 @@ +package gomushin.backend.member.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.event.dto.S3DeleteEvent +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.service.AnniversaryService +import gomushin.backend.couple.domain.service.CoupleInfoService +import gomushin.backend.couple.domain.service.CoupleService +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.member.domain.service.NotificationService +import gomushin.backend.schedule.domain.entity.Picture +import gomushin.backend.schedule.domain.service.CommentService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import io.mockk.* +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.context.ApplicationEventPublisher + +@ExtendWith(MockKExtension::class) +class LeaveFacadeTest { + @MockK + lateinit var anniversaryService: AnniversaryService + @MockK + lateinit var commentService: CommentService + @MockK + lateinit var coupleService: CoupleService + @MockK + lateinit var letterService: LetterService + @MockK + lateinit var memberService: MemberService + @MockK + lateinit var notificationService: NotificationService + @MockK + lateinit var pictureService: PictureService + @MockK + lateinit var scheduleService: ScheduleService + @MockK + lateinit var coupleInfoService: CoupleInfoService + @MockK + lateinit var applicationEventPublisher: ApplicationEventPublisher + + @InjectMockKs + lateinit var leaveFacade: LeaveFacade + + private val customUserDetails = mockk() + private val couple = mockk() + private val partner = mockk() + + @BeforeEach + fun setUp() { + every { customUserDetails.getId() } returns 1L + every { customUserDetails.getCouple() } returns couple + every { couple.id } returns 100L + every { partner.id } returns 2L + every { partner.updateIsCouple(false) } just Runs + } + + @DisplayName("leave 메서드는 회원 탈퇴를 성공적으로 처리한다.") + @Test + fun leave_success() { + // given + val memberId = 1L + val partnerId = 2L + val coupleId = 100L + val memberLetterIds = listOf(10L, 11L) + val partnerLetterIds = listOf(20L, 21L) + val pictures = listOf( + mockk { every { pictureUrl } returns "url1" }, + mockk { every { pictureUrl } returns "url2" } + ) + + every { coupleInfoService.findCoupleMember(memberId) } returns partner + every { anniversaryService.deleteAllByCoupleId(coupleId) } just Runs + every { commentService.deleteAllByMemberId(memberId) } just Runs + every { commentService.deleteAllByMemberId(partnerId) } just Runs + every { coupleService.deleteByMemberId(memberId) } just Runs + every { notificationService.deleteAllByMember(memberId) } just Runs + every { notificationService.deleteAllByMember(partnerId) } just Runs + every { scheduleService.deleteAllByMemberId(memberId) } just Runs + every { scheduleService.deleteAllByMemberId(partnerId) } just Runs + every { letterService.findAllByAuthorId(memberId) } returns memberLetterIds + every { letterService.findAllByAuthorId(partnerId) } returns partnerLetterIds + every { pictureService.findAllByLetterIds(memberLetterIds) } returns pictures + every { pictureService.findAllByLetterIds(partnerLetterIds) } returns emptyList() + every { pictureService.deleteAllByLetterIds(memberLetterIds) } just Runs + every { pictureService.deleteAllByLetterIds(partnerLetterIds) } just Runs + every { letterService.deleteAllByMemberId(memberId) } just Runs + every { letterService.deleteAllByMemberId(partnerId) } just Runs + every { memberService.clearMemberStatusMessage(memberId) } just Runs + every { memberService.clearMemberStatusMessage(partnerId) } just Runs + every { memberService.deleteMember(memberId) } just Runs + every { applicationEventPublisher.publishEvent(any()) } just Runs + + // when + leaveFacade.leave(customUserDetails) + + // then + verify(exactly = 1) { coupleInfoService.findCoupleMember(memberId) } + verify(exactly = 1) { anniversaryService.deleteAllByCoupleId(coupleId) } + verify(exactly = 1) { commentService.deleteAllByMemberId(memberId) } + verify(exactly = 1) { commentService.deleteAllByMemberId(partnerId) } + verify(exactly = 1) { coupleService.deleteByMemberId(memberId) } + verify(exactly = 1) { notificationService.deleteAllByMember(memberId) } + verify(exactly = 1) { notificationService.deleteAllByMember(partnerId) } + verify(exactly = 1) { scheduleService.deleteAllByMemberId(memberId) } + verify(exactly = 1) { scheduleService.deleteAllByMemberId(partnerId) } + verify(exactly = 1) { letterService.findAllByAuthorId(memberId) } + verify(exactly = 1) { letterService.findAllByAuthorId(partnerId) } + verify(exactly = 1) { pictureService.findAllByLetterIds(memberLetterIds) } + verify(exactly = 1) { pictureService.findAllByLetterIds(partnerLetterIds) } + verify(exactly = 1) { pictureService.deleteAllByLetterIds(memberLetterIds) } + verify(exactly = 1) { pictureService.deleteAllByLetterIds(partnerLetterIds) } + verify(exactly = 1) { letterService.deleteAllByMemberId(memberId) } + verify(exactly = 1) { letterService.deleteAllByMemberId(partnerId) } + verify(exactly = 1) { memberService.clearMemberStatusMessage(memberId) } + verify(exactly = 1) { memberService.clearMemberStatusMessage(partnerId) } + verify(exactly = 1) { partner.updateIsCouple(false) } + verify(exactly = 1) { memberService.deleteMember(memberId) } + verify(exactly = 1) { applicationEventPublisher.publishEvent(any()) } + } + + @DisplayName("사진이 없는 경우 S3DeleteEvent가 발생하지 않는다.") + @Test + fun leave_success_without_pictures() { + // given + val memberId = 1L + val partnerId = 2L + val coupleId = 100L + val memberLetterIds = listOf(10L, 11L) + val partnerLetterIds = listOf(20L, 21L) + + every { coupleInfoService.findCoupleMember(memberId) } returns partner + every { anniversaryService.deleteAllByCoupleId(coupleId) } just Runs + every { commentService.deleteAllByMemberId(any()) } just Runs + every { coupleService.deleteByMemberId(memberId) } just Runs + every { notificationService.deleteAllByMember(any()) } just Runs + every { scheduleService.deleteAllByMemberId(any()) } just Runs + every { letterService.findAllByAuthorId(memberId) } returns memberLetterIds + every { letterService.findAllByAuthorId(partnerId) } returns partnerLetterIds + every { pictureService.findAllByLetterIds(memberLetterIds) } returns emptyList() + every { pictureService.findAllByLetterIds(partnerLetterIds) } returns emptyList() + every { pictureService.deleteAllByLetterIds(any()) } just Runs + every { letterService.deleteAllByMemberId(any()) } just Runs + every { memberService.clearMemberStatusMessage(any()) } just Runs + every { memberService.deleteMember(memberId) } just Runs + + // when + leaveFacade.leave(customUserDetails) + + // then + verify(exactly = 0) { applicationEventPublisher.publishEvent(any()) } + verify(exactly = 1) { memberService.deleteMember(memberId) } + } +} diff --git a/src/test/kotlin/gomushin/backend/member/facade/MemberInfoFacadeTest.kt b/src/test/kotlin/gomushin/backend/member/facade/MemberInfoFacadeTest.kt new file mode 100644 index 00000000..092a2ee1 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/member/facade/MemberInfoFacadeTest.kt @@ -0,0 +1,198 @@ +package gomushin.backend.member.facade + +import gomushin.backend.alarm.service.StatusAlarmService +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.alarm.service.NotificationRedisService +import gomushin.backend.couple.domain.service.CoupleInfoService +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.entity.Notification +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.member.domain.service.NotificationService +import gomushin.backend.member.domain.value.Provider +import gomushin.backend.member.domain.value.Role +import gomushin.backend.member.dto.request.UpdateMyBirthdayRequest +import gomushin.backend.member.dto.request.UpdateMyEmotionAndStatusMessageRequest +import gomushin.backend.member.dto.request.UpdateMyNickNameRequest +import gomushin.backend.member.dto.request.UpdateMyNotificationRequest +import gomushin.backend.member.domain.value.Emotion +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.junit.jupiter.MockitoExtension +import java.time.LocalDate +import kotlin.test.assertEquals + +@ExtendWith(MockitoExtension::class) +class MemberInfoFacadeTest { + @Mock + private lateinit var memberService: MemberService + @Mock + private lateinit var notificationService: NotificationService + @Mock + private lateinit var statusAlarmService: StatusAlarmService + @Mock + private lateinit var coupleInfoService: CoupleInfoService + @Mock + private lateinit var notificationRedisService: NotificationRedisService + + @InjectMocks + private lateinit var memberInfoFacade: MemberInfoFacade + + private lateinit var customUserDetails: CustomUserDetails + private lateinit var member: Member + private lateinit var memberCouple : Member + private lateinit var notification: Notification + private lateinit var memberCoupleNotification : Notification + + @BeforeEach + fun setUp() { + member = Member( + id = 1L, + name = "테스트", + nickname = "테스트 닉네임", + email = "test@test.com", + birthDate = null, + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.MEMBER, + statusMessage = "상태 메시지" + ) + + memberCouple = Member( + id = 2L, + name = "멤버의 커플", + nickname = "멤버의 커플 닉네임", + email = "test@test.com", + birthDate = null, + profileImageUrl = null, + provider = Provider.KAKAO, + role = Role.MEMBER, + statusMessage = "상태 메시지" + ) + + notification = Notification( + id = 1L, + memberId = 1L, + dday = false, + partnerStatus = false + ) + + memberCoupleNotification = Notification ( + id = 2L, + memberId = 2L, + dday = false, + partnerStatus = true + ) + + customUserDetails = mock(CustomUserDetails::class.java) + + `when`(customUserDetails.getId()).thenReturn(1L) + + } + + @DisplayName("내 정보 조회 - [GUEST]") + @Test + fun getMyInfo() { + // given + `when`(memberService.getById(customUserDetails.getId())).thenReturn(member) + // when + val result = memberInfoFacade.getMemberInfo(customUserDetails) + // then + verify(memberService).getById(1L) + assertEquals(member.nickname, result.nickname) + } + + @DisplayName("내 상태 메시지 조회") + @Test + fun getMyStatusMessage() { + //given + `when`(memberService.getById(customUserDetails.getId())).thenReturn(member) + //when + val result = memberInfoFacade.getMyStatusMessage(customUserDetails) + //then + verify(memberService).getById(1L) + assertEquals(member.statusMessage, result.statusMessage) + } + + @DisplayName("이모지 및 상태 메시지 업데이트") + @Test + fun updateMyEmotionAndStatusMessage() { + //given + val updateMyEmotionAndStatusMessageRequest = UpdateMyEmotionAndStatusMessageRequest(Emotion.HAPPY, "좋은 날씨야") + `when`(coupleInfoService.findCoupleMember(customUserDetails.getId())).thenReturn(memberCouple) + `when`(notificationService.getByMemberId(memberCouple.id)).thenReturn(memberCoupleNotification) + `when`(memberService.getById(customUserDetails.getId())).thenReturn(member) + doNothing().`when`(statusAlarmService).sendStatusAlarm(member, memberCouple, updateMyEmotionAndStatusMessageRequest.emotion) + //when + val result = memberInfoFacade.updateMyEmotionAndStatusMessage(customUserDetails, updateMyEmotionAndStatusMessageRequest) + //then + verify(memberService).updateMyEmotionAndStatusMessage(1L, updateMyEmotionAndStatusMessageRequest) + verify(coupleInfoService).findCoupleMember(customUserDetails.getId()) + verify(notificationService).getByMemberId(memberCouple.id) + verify(memberService).getById(customUserDetails.getId()) + verify(statusAlarmService).sendStatusAlarm(member, memberCouple, updateMyEmotionAndStatusMessageRequest.emotion) + } + + @DisplayName("이모지 조회 테스트") + @Test + fun getMyEmotion() { + //given + `when`(memberService.getById(customUserDetails.getId())).thenReturn(member) + //when + val result = memberInfoFacade.getMemberEmotion(customUserDetails) + //then + verify(memberService).getById(1L) + assertEquals(member.emotion, result.emotion) + } + + @DisplayName("닉네임 수정") + @Test + fun updateMyNickname() { + //given + val updateMyNickNameRequest = UpdateMyNickNameRequest("테스트 닉네임 수정완료") + //when + val result = memberInfoFacade.updateMyNickname(customUserDetails, updateMyNickNameRequest) + //then + verify(memberService).updateMyNickname(1L, updateMyNickNameRequest) + } + + @DisplayName("생년월일 수정") + @Test + fun updateBirthDate() { + //given + val updateMyBirthdayRequest = UpdateMyBirthdayRequest(LocalDate.of(2001, 3, 30)) + //when + val result = memberInfoFacade.updateMyBirthDate(customUserDetails, updateMyBirthdayRequest) + //then + verify(memberService).updateMyBirthDate(1L, updateMyBirthdayRequest) + } + + @DisplayName("알림설정 수정") + @Test + fun updateNotification() { + //given + val updateMyNotificationRequest = UpdateMyNotificationRequest(true, false) + `when`(notificationService.getByMemberId(customUserDetails.getId())).thenReturn(notification) + //when + val result = memberInfoFacade.updateMyNotification(customUserDetails, updateMyNotificationRequest) + //then + assertEquals(notification.dday, updateMyNotificationRequest.dday) + assertEquals(notification.partnerStatus, updateMyNotificationRequest.partnerStatus) + } + + @DisplayName("알림정책 조회") + @Test + fun getMyNotification() { + //given + `when`(notificationService.getByMemberId(customUserDetails.getId())).thenReturn(notification) + //when + val result = memberInfoFacade.getMyNotification(customUserDetails) + //then + assertEquals(notification.dday, result.dday) + assertEquals(notification.partnerStatus, result.partnerStatus) + } +} diff --git a/src/test/kotlin/gomushin/backend/member/facade/OnboardingFacadeTest.kt b/src/test/kotlin/gomushin/backend/member/facade/OnboardingFacadeTest.kt new file mode 100644 index 00000000..538fd3af --- /dev/null +++ b/src/test/kotlin/gomushin/backend/member/facade/OnboardingFacadeTest.kt @@ -0,0 +1,39 @@ +package gomushin.backend.member.facade + +import gomushin.backend.member.domain.service.NotificationService +import gomushin.backend.member.domain.service.OnboardingService +import gomushin.backend.member.dto.request.OnboardingRequest +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.junit.jupiter.MockitoExtension + +@ExtendWith(MockitoExtension::class) +class OnboardingFacadeTest { + + @Mock + private lateinit var onboardingService: OnboardingService + + @Mock + private lateinit var notificationService: NotificationService + + @InjectMocks + private lateinit var onboardingFacade: OnboardingFacade + + @DisplayName("온보딩 테스트") + @Test + fun onboarding_success() { + // given + val id = 1L + val onboardingRequest = mock(OnboardingRequest::class.java) + // when + onboardingFacade.onboarding(id, onboardingRequest) + // then + verify(onboardingService).onboarding(id, onboardingRequest) + } + +} diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadLetterFacadeTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadLetterFacadeTest.kt new file mode 100644 index 00000000..fa273d23 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadLetterFacadeTest.kt @@ -0,0 +1,95 @@ +package gomushin.backend.schedule.domain.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.schedule.domain.entity.Letter +import gomushin.backend.schedule.domain.entity.Picture +import gomushin.backend.schedule.domain.entity.Schedule +import gomushin.backend.schedule.domain.service.CommentService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import gomushin.backend.schedule.facade.ReadLetterFacade +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.junit.jupiter.MockitoExtension +import kotlin.test.Test + +@ExtendWith(MockitoExtension::class) +class ReadLetterFacadeTest { + + @Mock + lateinit var letterService: LetterService + + @Mock + lateinit var scheduleService: ScheduleService + + @Mock + lateinit var commentService: CommentService + + @Mock + lateinit var pictureService: PictureService + + @Mock + lateinit var memberService: MemberService + + private lateinit var readLetterFacade: ReadLetterFacade + + + @BeforeEach + fun setUp() { + val baseUrl = "http://localhost:8080" + readLetterFacade = ReadLetterFacade( + letterService, + scheduleService, + pictureService, + commentService, + memberService, + baseUrl + ) + } + + @DisplayName("getList - 성공") + @Test + fun getList_success() { + // given + val scheduleId = 1L + val customUserDetails = mock(CustomUserDetails::class.java) + val schedule = mock(Schedule::class.java) + val letter = mock(Letter::class.java) + + // when + `when`(customUserDetails.getCouple()).thenReturn(mock(Couple::class.java)) + `when`(scheduleService.getById(scheduleId)).thenReturn(schedule) + `when`( + letterService.findByCoupleAndSchedule( + customUserDetails.getCouple(), + schedule + ) + ).thenReturn( + listOf( + letter + ) + ) + `when`(pictureService.findFirstByLetterId(letter.id)).thenReturn(mock(Picture::class.java)) + + readLetterFacade.getList( + customUserDetails, + scheduleId + ) + + // then + verify(scheduleService, times(1)).getById(scheduleId) + verify(letterService, times(1)).findByCoupleAndSchedule( + customUserDetails.getCouple(), + schedule + ) + verify(pictureService, times(1)).findFirstByLetterId(letter.id) + } + + // TODO: get 테스트 작성(성공) +} diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadScheduleFacadeMockkTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadScheduleFacadeMockkTest.kt new file mode 100644 index 00000000..5ec8cd84 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadScheduleFacadeMockkTest.kt @@ -0,0 +1,151 @@ +package gomushin.backend.schedule.domain.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.service.AnniversaryService +import gomushin.backend.couple.dto.response.MonthlyAnniversariesResponse +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import gomushin.backend.schedule.dto.response.* +import gomushin.backend.schedule.facade.ReadScheduleFacade +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import java.time.LocalDate + +@ExtendWith(MockKExtension::class) +class ReadScheduleFacadeMockkTest { + + @MockK + lateinit var scheduleService: ScheduleService + + @MockK + lateinit var anniversaryService: AnniversaryService + + @MockK + lateinit var letterService: LetterService + + @MockK + lateinit var pictureService: PictureService + + @MockK + lateinit var memberService: MemberService + + @InjectMockKs + lateinit var readScheduleFacade: ReadScheduleFacade + + private val customUserDetails = mockk() + + private val couple = mockk() + + init { + every { customUserDetails.getCouple() } returns couple + every { customUserDetails.getId() } returns 1L + } + + @DisplayName("getList - 성공") + @Test + fun getList_success() { + // given + val year = 2025 + val month = 4 + val monthlySchedulesResponse = mockk() + val monthlyAnniversariesResponse = mockk() + + // when + every { + scheduleService.findByCoupleAndYearAndMonth( + couple, + year, + month + ) + } returns listOf(monthlySchedulesResponse) + + every { + anniversaryService.findByCoupleAndYearAndMonth( + couple, + year, + month + ) + } returns listOf(monthlyAnniversariesResponse) + readScheduleFacade.getList(customUserDetails, year, month) + + // then + verify(exactly = 1) { + scheduleService.findByCoupleAndYearAndMonth(couple, year, month) + anniversaryService.findByCoupleAndYearAndMonth(couple, year, month) + } + } + + @DisplayName("getListByWeek - 성공") + @Test + fun getListByWeek_success() { + // given + val date = LocalDate.of(2025, 5, 22) + val mainSchedulesResponse = mockk() + val mainAnniversariesResponse = mockk() + + // when + every { + scheduleService.findByCoupleAndDateBetween( + couple, + date, + date.plusDays(6) + ) + } returns listOf(mainSchedulesResponse) + + every { + anniversaryService.findByCoupleAndDateBetween( + couple, + date, + date.plusDays(6) + ) + } returns listOf(mainAnniversariesResponse) + + + readScheduleFacade.getListByWeek(customUserDetails, date) + + // then + verify(exactly = 1) { + scheduleService.findByCoupleAndDateBetween( + couple, + date, + date.plusDays(6) + ) + anniversaryService.findByCoupleAndDateBetween( + couple, + date, + date.plusDays(6) + ) + } + } + + @DisplayName("get - 성공") + @Test + fun get_success() { + // given + val date = LocalDate.of(2025, 4, 1) + val mockSchedules = listOf(mockk()) + val mockAnniversaries = listOf(mockk()) + + // when + every { scheduleService.findByDate(couple, date) } returns mockSchedules + every { anniversaryService.findByCoupleAndDate(couple, date) } returns mockAnniversaries + readScheduleFacade.get(customUserDetails, date) + + // then + verify(exactly = 1) { + scheduleService.findByDate(couple, date) + anniversaryService.findByCoupleAndDate(couple, date) + } + } +} diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadScheduleFacadeTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadScheduleFacadeTest.kt new file mode 100644 index 00000000..d7fd9831 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/schedule/domain/facade/ReadScheduleFacadeTest.kt @@ -0,0 +1,91 @@ +package gomushin.backend.schedule.domain.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.couple.domain.service.AnniversaryService +import gomushin.backend.couple.dto.response.MonthlyAnniversariesResponse +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.schedule.domain.entity.Letter +import gomushin.backend.schedule.domain.entity.Picture +import gomushin.backend.schedule.domain.entity.Schedule +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.domain.service.PictureService +import gomushin.backend.schedule.domain.service.ScheduleService +import gomushin.backend.schedule.dto.response.DailyScheduleResponse +import gomushin.backend.schedule.dto.response.MonthlySchedulesResponse +import gomushin.backend.schedule.facade.ReadScheduleFacade +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.* +import org.mockito.junit.jupiter.MockitoExtension +import java.time.LocalDate +import java.time.LocalDateTime + +@ExtendWith(MockitoExtension::class) +class ReadScheduleFacadeTest { + + @Mock + private lateinit var scheduleService: ScheduleService + + @Mock + private lateinit var anniversaryService: AnniversaryService + + @Mock + private lateinit var letterService: LetterService + + @Mock + private lateinit var pictureService: PictureService + + @Mock + private lateinit var memberService: MemberService + + @InjectMocks + private lateinit var readScheduleFacade: ReadScheduleFacade + + private val customUserDetails = mock(CustomUserDetails::class.java) + + @BeforeEach + fun setUp() { + `when`(customUserDetails.getCouple()).thenReturn(mock(Couple::class.java)) + } + + @DisplayName("getDetail - 성공") + @Test + fun getDetail_success() { + //given + val scheduleId = 1L + val letterId = 2L + val mockLetter = mock(Letter::class.java) + val mockPicture = mock(Picture::class.java) + + val schedule = Schedule( + id = scheduleId, + title = "일정 제목", + fatigue = "VERT_TIRED", + startDate = LocalDateTime.of(2025, 5, 1, 7, 0, 0), + endDate = LocalDateTime.of(2025, 5, 2, 20, 0, 0) + ) + + //when + `when`(mockLetter.id).thenReturn(letterId) + `when`(mockPicture.letterId).thenReturn(letterId) + `when`(scheduleService.getById(scheduleId)).thenReturn(schedule) + `when`(letterService.findByCoupleAndSchedule(customUserDetails.getCouple(), schedule)).thenReturn( + listOf( + mockLetter + ) + ) + `when`(pictureService.findAllByLetterIds(listOf(letterId))).thenReturn(listOf(mockPicture)) + + readScheduleFacade.getDetail(customUserDetails, scheduleId) + + //then + verify(scheduleService).getById(scheduleId) + verify(letterService).findByCoupleAndSchedule(customUserDetails.getCouple(), schedule) + verify(pictureService).findAllByLetterIds(listOf(letterId)) + } +} diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/facade/UpsertAndDeleteCommentFacadeTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/facade/UpsertAndDeleteCommentFacadeTest.kt new file mode 100644 index 00000000..f4b5bb96 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/schedule/domain/facade/UpsertAndDeleteCommentFacadeTest.kt @@ -0,0 +1,176 @@ +package gomushin.backend.schedule.domain.facade + +import gomushin.backend.core.CustomUserDetails +import gomushin.backend.core.common.support.SpringContextHolder +import gomushin.backend.core.configuration.env.AppEnv +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.member.domain.entity.Member +import gomushin.backend.member.domain.service.MemberService +import gomushin.backend.schedule.domain.entity.Comment +import gomushin.backend.schedule.domain.entity.Letter +import gomushin.backend.schedule.domain.service.CommentService +import gomushin.backend.schedule.domain.service.LetterService +import gomushin.backend.schedule.dto.request.UpsertCommentRequest +import gomushin.backend.schedule.facade.UpsertAndDeleteCommentFacade +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.context.ApplicationContext +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class UpsertAndDeleteCommentFacadeTest { + + @MockK + private lateinit var commentService: CommentService + + @MockK + private lateinit var letterService: LetterService + + @MockK + private lateinit var memberService: MemberService + + @MockK(relaxed = true) + private lateinit var mockAppEnv: AppEnv + + @MockK + private lateinit var mockApplicationContext: ApplicationContext + + @InjectMockKs + private lateinit var upsertAndDeleteCommentFacade: UpsertAndDeleteCommentFacade + + @BeforeEach + fun setup() { + SpringContextHolder.context = mockApplicationContext + every { mockApplicationContext.getBean(AppEnv::class.java) } returns mockAppEnv + every { mockAppEnv.getId() } returns "test-env" + } + + + @DisplayName("댓글 생성 또는 수정 성공") + @Test + fun upsert_success() { + // given + val customUserDetails = mockk() + val letterId = 1L + val upsertCommentRequest = mockk() + val member = mockk() + val letter = mockk() + every { customUserDetails.getId() } returns 1L + every { memberService.getById(any()) } returns member + every { letterService.getById(any()) } returns letter + every { upsertCommentRequest.commentId } returns 1L + every { letter.id } returns letterId + every { member.id } returns 1L + every { member.nickname } returns "닉네임" + every { commentService.upsert(any(), any(), any(), any(), any()) } returns Unit + + // when + upsertAndDeleteCommentFacade.upsert(customUserDetails, letterId, upsertCommentRequest) + + // then + verify { memberService.getById(1L) } + verify { letterService.getById(1L) } + verify { commentService.upsert(1L, 1L, 1L, "닉네임", upsertCommentRequest) } + } + + + @Nested + inner class DeleteTest { + @DisplayName("댓글 삭제 성공") + @Test + fun delete_success() { + // given + val customUserDetails = mockk() + val letterId = 1L + val memberId = 1L + val commentId = 1L + val authorId = 1L + val member = mockk() + val comment = mockk() + + every { customUserDetails.getId() } returns 1L + every { memberService.getById(any()) } returns member + every { commentService.getById(commentId) } returns comment + every { comment.authorId } returns authorId + every { member.id } returns memberId + every { comment.letterId } returns letterId + every { commentService.delete(commentId) } returns Unit + + // when + upsertAndDeleteCommentFacade.delete(customUserDetails, letterId, commentId) + + // then + verify { memberService.getById(1L) } + verify { commentService.getById(commentId) } + verify { commentService.delete(commentId) } + } + + @DisplayName("댓글 삭제 시 comment 작성자 ID와 Member ID가 다를 경우, 에러 발생") + @Test + fun delete_shouldThrowBadRequestException_When_Comment_AuthorId_is_Not_MemberId() { + // given + val customUserDetails = mockk() + val letterId = 1L + val memberId = 2L + val commentId = 1L + val authorId = 1L + val member = mockk() + val comment = mockk() + + every { customUserDetails.getId() } returns 1L + every { memberService.getById(any()) } returns member + every { commentService.getById(commentId) } returns comment + every { comment.authorId } returns authorId + every { member.id } returns memberId + every { comment.letterId } returns letterId + every { commentService.delete(commentId) } returns Unit + + // when + val exception = shouldThrow { + upsertAndDeleteCommentFacade.delete(customUserDetails, letterId, commentId) + } + + // then + exception.error.element.message.resolved shouldBe "댓글은 작성자만 삭제하거나 업데이트 할 수 있어요." + } + + @DisplayName("댓글의 letterId와 입력으로 받은 letterId가 다른 경우, 예외를 발생시킨다.") + @Test + fun delete_shouldThrowBadRequestException_when_commentLetterId_and_letterId_not_match() { + // given + val customUserDetails = mockk() + val letterId = 1L + val memberId = 1L + val commentId = 1L + val authorId = 1L + val member = mockk() + val comment = mockk() + + every { customUserDetails.getId() } returns 1L + every { memberService.getById(any()) } returns member + every { commentService.getById(commentId) } returns comment + every { comment.authorId } returns authorId + every { member.id } returns memberId + every { comment.letterId } returns 2L + every { commentService.delete(commentId) } returns Unit + + // when + val exception = shouldThrow { + upsertAndDeleteCommentFacade.delete(customUserDetails, letterId, commentId) + } + + // then + exception.error.element.message.resolved shouldBe "편지에 해당하는 댓글이 아니에요." + } + } +} diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/repository/ScheduleRepositoryTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/repository/ScheduleRepositoryTest.kt new file mode 100644 index 00000000..51361427 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/schedule/domain/repository/ScheduleRepositoryTest.kt @@ -0,0 +1,87 @@ +package gomushin.backend.schedule.domain.repository + +import gomushin.backend.couple.domain.entity.Couple +import gomushin.backend.schedule.domain.entity.Schedule +import gomushin.backend.schedule.dto.response.MonthlySchedulesResponse +import org.hamcrest.MatcherAssert +import org.hamcrest.Matchers +import org.hamcrest.Matchers.containsInAnyOrder +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime +import java.time.Month + +@DataJpaTest +@Transactional +@ExtendWith(MockitoExtension::class) +class ScheduleRepositoryTest @Autowired constructor( + val scheduleRepository : ScheduleRepository +){ + private lateinit var couple : Couple + @BeforeEach + fun setup() { + couple = Couple.of(1L, 2L); + val scheduleList = listOf( + Schedule.of( + couple.id, + 1L, + "시작월과 끝월이 같음", + LocalDateTime.of(2025, 7, 21, 0, 0, 0, 0), + LocalDateTime.of(2025, 7,22,0,0,0,0), + "VERY_TIRED", + true), + Schedule.of( + couple.id, + 1L, + "시작월과 끝월이 다름", + LocalDateTime.of(2025, 7, 21, 0, 0, 0, 0), + LocalDateTime.of(2025, 8,1,0,0,0,0), + "VERY_TIRED", + true) + ) + scheduleRepository.saveAll(scheduleList); + } + @DisplayName("startDate 기준으로 검색이 되는지 테스트") + @Test + fun filters_schedules_by_startDate_within_given_year_and_month() { + //when + val responseList = scheduleRepository.findByCoupleIdAndYearAndMonth(couple.id, 2025, 7) + //then + assertEquals(2, responseList.size) + assertTrue( + responseList.stream().allMatch { i: MonthlySchedulesResponse -> + (i.startDate.year == 2025 && i.endDate.year == 2025) + && + (i.startDate.month == Month.JULY || i.endDate.month == Month.JULY) + } + ) + val actualContents: List = responseList.map { it.title } + MatcherAssert.assertThat(actualContents, containsInAnyOrder("시작월과 끝월이 같음", "시작월과 끝월이 다름")) + } + + @DisplayName("endDate 기준으로 검색이 되는지 테스트") + @Test + fun filters_schedules_by_endDate_within_given_year_and_month() { + //when + val responseList = scheduleRepository.findByCoupleIdAndYearAndMonth(couple.id, 2025, 8) + //then + assertEquals(1, responseList.size) + assertTrue( + responseList.stream().allMatch { i: MonthlySchedulesResponse -> + (i.startDate.year == 2025 && i.endDate.year == 2025) + && + (i.startDate.month == Month.AUGUST || i.endDate.month == Month.AUGUST) + } + ) + val actualContents: List = responseList.map { it.title } + MatcherAssert.assertThat(actualContents, containsInAnyOrder("시작월과 끝월이 다름")) + } +} \ No newline at end of file diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/service/CommentServiceTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/service/CommentServiceTest.kt new file mode 100644 index 00000000..86fc2537 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/schedule/domain/service/CommentServiceTest.kt @@ -0,0 +1,217 @@ +package gomushin.backend.schedule.domain.service + +import gomushin.backend.core.common.support.SpringContextHolder +import gomushin.backend.core.configuration.env.AppEnv +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.schedule.domain.entity.Comment +import gomushin.backend.schedule.domain.repository.CommentRepository +import gomushin.backend.schedule.dto.request.UpsertCommentRequest +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.context.ApplicationContext +import org.springframework.data.repository.findByIdOrNull +import kotlin.test.Test + +@ExtendWith(MockKExtension::class) +class CommentServiceTest { + + @MockK + private lateinit var commentRepository: CommentRepository + + @MockK(relaxed = true) + private lateinit var mockAppEnv: AppEnv + + @MockK + private lateinit var mockApplicationContext: ApplicationContext + + @InjectMockKs + private lateinit var commentService: CommentService + + @BeforeEach + fun setup() { + SpringContextHolder.context = mockApplicationContext + every { mockApplicationContext.getBean(AppEnv::class.java) } returns mockAppEnv + every { mockAppEnv.getId() } returns "test-env" + } + + @Nested + inner class UpsertTest { + @DisplayName("id가 입력으로 들어오지 않는 경우, 댓글을 생성한다.") + @Test + fun upsert_shouldCreateComment_When_ParameterId_NotExists() { + // given + val letterId = 1L + val authorId = 1L + val nickname = "닉네임" + val upsertCommentRequest = mockk() + val comment = mockk() + every { upsertCommentRequest.content } returns "훈련 힘내" + every { upsertCommentRequest.commentId } returns 1L + every { commentRepository.save(any()) } returns comment + + // when + commentService.upsert(null, letterId, authorId, nickname, upsertCommentRequest) + + // then + verify { commentRepository.save(any()) } + } + + @DisplayName("id로 찾은 댓글의 작성자와 수정하려는 authorId가 다른 경우, 에러를 반환한다.") + @Test + fun upsert_shouldThrowException_When_CommentAuthorId_And_AuthorId_Not_Matched() { + // given + val id = 1L + val letterId = 1L + val authorId = 1L + val nickname = "닉네임" + val upsertCommentRequest = mockk() + val comment = mockk() + every { commentRepository.findByIdOrNull(1L) } returns comment + every { upsertCommentRequest.content } returns "훈련 힘내" + every { upsertCommentRequest.commentId } returns 1L + every { comment.authorId } returns 2L + + // when + val exception = shouldThrow { + commentService.upsert(id, letterId, authorId, nickname, upsertCommentRequest) + } + + // then + exception.error.element.message.resolved shouldBe "댓글은 작성자만 삭제하거나 업데이트 할 수 있어요." + } + } + + @Nested + inner class ReadTest { + @DisplayName("존재하지 않는 댓글 ID로 조회 시 BadRequestException 발생") + @Test + fun getById_shouldThrowBadRequestException_When_NotExistId() { + every { commentRepository.findByIdOrNull(any()) } returns null + + val exception = shouldThrow { + commentService.getById(999L) + } + + exception.error.element.message.resolved shouldBe "댓글을 찾을 수 없어요." + } + + @DisplayName("존재하는 댓글 ID로 조회 시 댓글 객체 반환") + @Test + fun getById_success() { + // given + val id = 1L + val comment = mockk() + every { commentRepository.findByIdOrNull(id) } returns comment + // when + val result = commentService.getById(id) + + // then + result shouldBe comment + verify { commentRepository.findByIdOrNull(id) } + verify { commentService.findById(id) } + } + + @DisplayName("findById 호출 시 존재하지 않는 ID로 조회 시 null 반환") + @Test + fun findById_shouldReturnNull_When_NotExistId() { + // given + val id = 999L + every { commentRepository.findByIdOrNull(id) } returns null + + // when + val result = commentService.findById(id) + + // then + result shouldBe null + verify { commentRepository.findByIdOrNull(id) } + } + + @DisplayName("findById 호출 시 존재하는 ID로 조회 시 댓글 객체 반환") + @Test + fun findById_shouldReturnComment_When_ExistId() { + // given + val id = 1L + val comment = mockk() + every { commentRepository.findByIdOrNull(id) } returns comment + + // when + val result = commentService.findById(id) + + // then + result shouldBe comment + verify { commentRepository.findByIdOrNull(id) } + } + + @DisplayName("findAllByLetterId 호출 시 댓글 리스트 반환") + @Test + fun findAllByLetterId_success() { + // given + val letterId = 1L + val comments = listOf(mockk(), mockk()) + every { commentRepository.findAllByLetterId(letterId) } returns comments + + // when + val result = commentService.findAllByLetterId(letterId) + + // then + result shouldBe comments + verify { commentRepository.findAllByLetterId(letterId) } + } + } + + @DisplayName("save 호출 시 댓글 저장 후 반환") + @Test + fun save_shouldReturnSavedComment() { + // given + val comment = mockk() + every { commentRepository.save(comment) } returns comment + // when + val result = commentService.save(comment) + + // then + result shouldBe comment + verify { commentRepository.save(comment) } + } + + @Nested + inner class DeleteTest { + @DisplayName("delete 호출 시 댓글 삭제") + @Test + fun delete_shouldDeleteComment() { + // given + val id = 1L + every { commentRepository.deleteById(id) } returns Unit + + // when + commentService.delete(id) + + // then + verify { commentRepository.deleteById(id) } + } + + @DisplayName("deleteAllByLetterId 호출 시 commentRepository.deleteAllByLetterId 가 1회 호출된다.") + @Test + fun deleteAllByLetterId_shouldCallDeleteAllByLetterId() { + // given + val letterId = 1L + every { commentRepository.deleteAllByLetterId(any()) } returns Unit + + // when + commentService.deleteAllByLetterId(letterId) + + // then + verify { commentRepository.deleteAllByLetterId(letterId) } + } + } + +} diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/service/PictureServiceTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/service/PictureServiceTest.kt new file mode 100644 index 00000000..c8bc0939 --- /dev/null +++ b/src/test/kotlin/gomushin/backend/schedule/domain/service/PictureServiceTest.kt @@ -0,0 +1,37 @@ +package gomushin.backend.schedule.domain.service + +import gomushin.backend.schedule.domain.repository.PictureRepository +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.anyList +import org.mockito.Mockito.verify +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.times +import kotlin.test.Test + +@ExtendWith(MockitoExtension::class) +class PictureServiceTest { + + @Mock + private lateinit var pictureRepository: PictureRepository + + @InjectMocks + private lateinit var pictureService: PictureService + + @DisplayName("upsert 성공") + @Test + fun upsert_success() { + // given + val letterId = 1L + val pictureUrls = listOf("http://example.com/test.jpg") + + // when + pictureService.upsert(letterId, pictureUrls) + + // then + verify(pictureRepository).deleteAllByLetterId(letterId) + verify(pictureRepository, times(1)).saveAll(anyList()) + } +} diff --git a/src/test/kotlin/gomushin/backend/schedule/domain/service/ScheduleServiceTest.kt b/src/test/kotlin/gomushin/backend/schedule/domain/service/ScheduleServiceTest.kt new file mode 100644 index 00000000..82c80c5f --- /dev/null +++ b/src/test/kotlin/gomushin/backend/schedule/domain/service/ScheduleServiceTest.kt @@ -0,0 +1,158 @@ +package gomushin.backend.schedule.domain.service + +import gomushin.backend.core.common.support.SpringContextHolder +import gomushin.backend.core.configuration.env.AppEnv +import gomushin.backend.core.infrastructure.exception.BadRequestException +import gomushin.backend.schedule.domain.entity.Schedule +import gomushin.backend.schedule.domain.repository.ScheduleRepository +import gomushin.backend.schedule.dto.request.UpsertScheduleRequest +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.impl.annotations.InjectMockKs +import io.mockk.impl.annotations.MockK +import io.mockk.junit5.MockKExtension +import io.mockk.justRun +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.context.ApplicationContext +import org.springframework.data.repository.findByIdOrNull +import java.time.LocalDateTime + + +@ExtendWith(MockKExtension::class) +class ScheduleServiceTest { + + @MockK + lateinit var scheduleRepository: ScheduleRepository + + @MockK(relaxed = true) + private lateinit var mockAppEnv: AppEnv + + @MockK + private lateinit var mockApplicationContext: ApplicationContext + + + @InjectMockKs + lateinit var scheduleService: ScheduleService + + @BeforeEach + fun setup() { + SpringContextHolder.context = mockApplicationContext + every { mockApplicationContext.getBean(AppEnv::class.java) } returns mockAppEnv + every { mockAppEnv.getId() } returns "test-env" + } + + @Nested + inner class UpsertTest { + @DisplayName("일정 저장 테스트 - 성공") + @Test + fun insert_success() { + // given + val coupleId = 1L + val userId = 1L + val upsertScheduleRequest = mockk() + every { upsertScheduleRequest.toEntity(coupleId, userId) } returns mockk() + every { scheduleRepository.save(any()) } returns mockk() + + // when + scheduleService.upsert(null, coupleId, userId, upsertScheduleRequest) + + // then + verify { scheduleRepository.save(any()) } + } + + @DisplayName("일정 수정 테스트 - 성공") + @Test + fun update_success() { + // given + val id = 1L + val scheduleId = 1L + val coupleId = 1L + val userId = 1L + val upsertScheduleRequest = mockk() + val schedule = Schedule( + id = scheduleId, + coupleId = coupleId, + userId = userId, + startDate = LocalDateTime.of(2023, 1, 1, 0, 0), + endDate = LocalDateTime.of(2023, 1, 2, 0, 0), + title = "Test Schedule", + isAllDay = false, + fatigue = "VERY_TIRED", + ) + + + every { upsertScheduleRequest.title } returns "Updated Schedule" + every { upsertScheduleRequest.fatigue } returns "TIRED" + every { upsertScheduleRequest.isAllDay } returns true + every { upsertScheduleRequest.id } returns id + every { upsertScheduleRequest.startDate } returns LocalDateTime.of(2023, 1, 1, 0, 0) + every { upsertScheduleRequest.endDate } returns LocalDateTime.of(2023, 1, 2, 0, 0) + + every { scheduleRepository.findByIdOrNull(id) } returns schedule + every { scheduleRepository.save(schedule) } returns schedule + + // when + scheduleService.upsert(id, coupleId, userId, upsertScheduleRequest) + + // then + verify { scheduleRepository.findByIdOrNull(id) } + verify(exactly = 0) { + scheduleRepository.save(any()) + } + } + } + + @Nested + inner class ReadTest { + @DisplayName("존재하지 않는 ID로 검색 시 예외 발생") + @Test + fun getById_notExistId_throwBadRequestException() { + // given + every { scheduleRepository.findByIdOrNull(any()) } returns null + + // when & then + val exception = shouldThrow { + scheduleService.getById(1L) + } + exception.error.element.message.resolved shouldBe "해당 일정이 존재하지 않아요." + } + } + + @Nested + inner class DeleteTest { + @DisplayName("일정 삭제 테스트 - 성공") + @Test + fun delete_success() { + // given + val coupleId = 1L + val userId = 1L + val scheduleId = 1L + val schedule = Schedule( + id = scheduleId, + coupleId = coupleId, + userId = userId, + startDate = LocalDateTime.of(2023, 1, 1, 0, 0), + endDate = LocalDateTime.of(2023, 1, 2, 0, 0), + title = "Test Schedule", + isAllDay = false, + fatigue = "VERY_TIRED", + ) + + every { scheduleRepository.findByIdOrNull(scheduleId) } returns schedule + justRun { scheduleRepository.deleteById(scheduleId) } + + // when + scheduleService.delete(coupleId, userId, scheduleId) + + // then + verify { scheduleRepository.deleteById(scheduleId) } + } + } +}