diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e586ecf..715cf16c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,7 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout @@ -16,30 +16,22 @@ jobs: uses: actions/setup-java@v3 with: java-version: 17 - distribution: 'temurin' + distribution: 'corretto' cache: gradle - - name: Create application-secret.yml - run: | - pwd - cd ./smeem-bootstrap/src/main/resources - touch ./application-secret.yml - echo "${{ secrets.APPLICATION_SECRET_YML }}" >> ./application-secret.yml - cat ./application-secret.yml - - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v1 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_DEV }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY_DEV }} aws-region: ${{ secrets.AWS_REGION }} - name: Set FCM_JSON_PATH - run: echo "FCM_JSON_PATH=smeem-output-notification/src/main/resources/firebase-config/smeem_fcm_dev.json" >> $GITHUB_ENV + run: | + echo "FCM_JSON_PATH=smeem-output-notification/src/main/resources/firebase-config/smeem_fcm_dev.json" >> $GITHUB_ENV - name: Create FireBase JSON file From AWS run: aws s3 cp --region ap-northeast-2 ${{ secrets.AWS_S3_FCM_JSON_URI_DEV }} ${{ env.FCM_JSON_PATH }} - - name: Grant execute permission for gradlew run: chmod +x ./gradlew shell: bash diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 2401ceae..50d1d8c7 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -1,71 +1,75 @@ -name: deploy-dev +name: CD-dev on: release: - types: [ published ] + types: [ "published" ] jobs: - build: - runs-on: ubuntu-20.04 + deploy-ci: + runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout Latest Repo uses: actions/checkout@v3 + # 최신 저장소에서 체크아웃 - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: 17 - distribution: 'temurin' - cache: gradle + distribution: 'corretto' + java-version: '17' + # 자바 버전을 JDK 17로 설정 - - name: Create application-secret.yml - run: | - pwd - cd ./smeem-bootstrap/src/main/resources - touch ./application-secret.yml - echo "${{ secrets.APPLICATION_SECRET_YML }}" >> ./application-secret.yml - cat ./application-secret.yml - - - name: Configure AWS credentials + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v1 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY }} - aws-region: ${{ secrets.AWS_REGION }} + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_DEV }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_KEY_DEV }} + aws-region: ap-northeast-2 + # AWS 자격 증명 설정 - - name: Set FCM_JSON_PATH - run: echo "FCM_JSON_PATH=smeem-output-notification/src/main/resources/firebase-config/smeem_fcm_dev.json" >> $GITHUB_ENV + - name: Set FCM JSON File Path + run: | + echo "FCM_JSON_PATH=smeem-output-notification/src/main/resources/firebase-config/smeem_fcm_dev.json" >> $GITHUB_ENV + # FCM JSON 파일 위치 경로 설정 - - name: Create FireBase JSON file From AWS + - name: Create FCM JSON File from AWS run: aws s3 cp --region ap-northeast-2 ${{ secrets.AWS_S3_FCM_JSON_URI_DEV }} ${{ env.FCM_JSON_PATH }} + # FCM JSON 파일 복사 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + shell: bash + # gradlew 파일에 실행 권한 부여 - name: Build with Gradle - run: | - chmod +x ./gradlew - ./gradlew build -x test + run: ./gradlew clean build -x test shell: bash + # 그래들로 빌드 - - name: Set docker + - name: Set Docker uses: docker/setup-buildx-action@v2.9.1 + # 도커 설정 - - name: Login docker + - name: Login Docker uses: docker/login-action@v2.2.0 with: username: ${{ secrets.DOCKERHUB_LOGIN_USERNAME_DEV }} password: ${{ secrets.DOCKERHUB_LOGIN_ACCESSTOKEN_DEV }} + # 도커에 로그인 - - name: Build docker image + - name: Docker Build run: | docker build --platform linux/amd64 -t smeemdev/smeem-dev:latest -f Dockerfile-dev . docker push smeemdev/smeem-dev:latest deploy-cd: - needs: build - runs-on: ubuntu-20.04 + needs: deploy-ci + runs-on: ubuntu-latest + # deploy-ci 이후 작업 실행 steps: - - name: SSH로 서버 접속 + - name: Connect to EC2 and Deploy uses: appleboy/ssh-action@master with: host: ${{ secrets.RELEASE_SERVER_IP_DEV }} @@ -78,19 +82,14 @@ jobs: wget https://raw.githubusercontent.com/Team-Smeme/Smeme-server-renewal/develop/script/deploy.sh -O deploy.sh chmod +x deploy.sh - # .env 파일 추가 - if ! grep -q "REGISTRY_URL=" .env; then - echo "REGISTRY_URL=${{ secrets.REGISTRY_URL_DEV }}" >> .env - fi - if ! grep -q "IMAGE_NAME=" .env; then - echo "IMAGE_NAME=${{ secrets.IMAGE_NAME_DEV }}" >> .env - fi - if ! grep -q "SECRET_MANAGER_TOKEN=" .env; then - echo "SECRET_MANAGER_TOKEN=${{ secrets.SECRET_MANAGER_TOKEN }}" >> .env - fi - if ! grep -q "SECRET_MANAGER_WORKSPACE_ID=" .env; then - echo "SECRET_MANAGER_WORKSPACE_ID=${{ secrets.SECRET_MANAGER_WORKSPACE_ID }}" >> .env - fi + # 기존 .env 파일 삭제 및 새로 생성 + rm -f .env # 기존 .env 파일을 삭제합니다. + + # .env 파일 새로 생성 + echo "REGISTRY_URL=${{ secrets.REGISTRY_URL_DEV }}" >> .env + echo "IMAGE_NAME=${{ secrets.IMAGE_NAME_DEV }}" >> .env + echo "SECRET_MANAGER_TOKEN=${{ secrets.SECRET_MANAGER_TOKEN }}" >> .env + echo "SECRET_MANAGER_WORKSPACE_ID=${{ secrets.SECRET_MANAGER_WORKSPACE_ID }}" >> .env # 배포 스크립트 실행 sudo ./deploy.sh diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 17dfe276..ba6bcd01 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -1,71 +1,75 @@ -name: deploy-prod +name: CD-prod on: push: branches: [ main ] jobs: - build: - runs-on: ubuntu-20.04 + deploy-ci: + runs-on: ubuntu-latest steps: - - name: Checkout + - name: Checkout Latest Repo uses: actions/checkout@v3 + # 최신 저장소에서 체크아웃 - name: Set up JDK 17 uses: actions/setup-java@v3 with: - java-version: 17 - distribution: 'temurin' - cache: gradle + distribution: 'corretto' + java-version: '17' + # 자바 버전을 JDK 17로 설정 - - name: Create application-secret.yml - run: | - pwd - cd ./smeem-bootstrap/src/main/resources - touch ./application-secret.yml - echo "${{ secrets.APPLICATION_SECRET_YML }}" >> ./application-secret.yml - cat ./application-secret.yml - - - name: Configure AWS credentials + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v1 with: aws-access-key-id: ${{ secrets.AWS_PROD_ACCESS_KEY }} aws-secret-access-key: ${{ secrets.AWS_PROD_SECRET_KEY }} - aws-region: ${{ secrets.AWS_REGION }} + aws-region: ap-northeast-2 + # AWS 자격 증명 설정 - - name: Set FCM_JSON_PATH - run: echo "FCM_JSON_PATH=smeem-output-notification/src/main/resources/firebase-config/smeem_fcm_prod.json" >> $GITHUB_ENV + - name: Set FCM JSON File Path + run: | + echo "FCM_JSON_PATH=smeem-output-notification/src/main/resources/firebase-config/smeem_fcm_prod.json" >> $GITHUB_ENV + # FCM JSON 파일 위치 경로 설정 - - name: Create FireBase JSON file From AWS + - name: Create FCM JSON File from AWS run: aws s3 cp --region ap-northeast-2 ${{ secrets.AWS_S3_FCM_JSON_URI_PROD }} ${{ env.FCM_JSON_PATH }} + # FCM JSON 파일 복사 + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + shell: bash + # gradlew 파일에 실행 권한 부여 - name: Build with Gradle - run: | - chmod +x ./gradlew - ./gradlew build -x test + run: ./gradlew clean build -x test shell: bash + # 그래들로 빌드 - - name: Set docker + - name: Set Docker uses: docker/setup-buildx-action@v2.9.1 + # 도커 설정 - - name: Login docker + - name: Login Docker uses: docker/login-action@v2.2.0 with: username: ${{ secrets.DOCKERHUB_LOGIN_USERNAME_PROD }} password: ${{ secrets.DOCKERHUB_LOGIN_ACCESSTOKEN_PROD }} + # 도커에 로그인 - - name: Build docker image + - name: Docker Build run: | docker build --platform linux/amd64 -t smeemprod/smeem-prod:latest -f Dockerfile-prod . docker push smeemprod/smeem-prod:latest deploy-cd: - needs: build - runs-on: ubuntu-20.04 + needs: deploy-ci + runs-on: ubuntu-latest + # deploy-ci 이후 작업 실행 steps: - - name: SSH로 서버 접속 + - name: Connect to EC2 and Deploy uses: appleboy/ssh-action@master with: host: ${{ secrets.RELEASE_SERVER_IP_PROD }} @@ -78,19 +82,14 @@ jobs: wget https://raw.githubusercontent.com/Team-Smeme/Smeme-server-renewal/main/script/deploy.sh -O deploy.sh chmod +x deploy.sh - # .env 파일 추가 - if ! grep -q "REGISTRY_URL=" .env; then - echo "REGISTRY_URL=${{ secrets.REGISTRY_URL_PROD }}" >> .env - fi - if ! grep -q "IMAGE_NAME=" .env; then - echo "IMAGE_NAME=${{ secrets.IMAGE_NAME_PROD }}" >> .env - fi - if ! grep -q "SECRET_MANAGER_TOKEN=" .env; then - echo "SECRET_MANAGER_TOKEN=${{ secrets.SECRET_MANAGER_TOKEN }}" >> .env - fi - if ! grep -q "SECRET_MANAGER_WORKSPACE_ID=" .env; then - echo "SECRET_MANAGER_WORKSPACE_ID=${{ secrets.SECRET_MANAGER_WORKSPACE_ID }}" >> .env - fi + # 기존 .env 파일 삭제 및 새로 생성 + rm -f .env # 기존 .env 파일을 삭제합니다. + + # .env 파일 새로 생성 + echo "REGISTRY_URL=${{ secrets.REGISTRY_URL_PROD }}" >> .env + echo "IMAGE_NAME=${{ secrets.IMAGE_NAME_PROD }}" >> .env + echo "SECRET_MANAGER_TOKEN=${{ secrets.SECRET_MANAGER_TOKEN }}" >> .env + echo "SECRET_MANAGER_WORKSPACE_ID=${{ secrets.SECRET_MANAGER_WORKSPACE_ID }}" >> .env # 배포 스크립트 실행 sudo ./deploy.sh diff --git a/README.md b/README.md index d838ea8d..58aed8eb 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ## Architecture ### Server Architecture -image +image
diff --git a/build.gradle b/build.gradle index ddbf7a28..4579b10b 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.2.1' + id 'org.springframework.boot' version '3.3.4' id 'io.spring.dependency-management' version '1.1.4' id 'application' } @@ -12,6 +12,8 @@ allprojects { repositories { mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://repo.spring.io/snapshot' } } } diff --git a/docker/monitoring/docker-compose.yml b/docker/monitoring/docker-compose.yml new file mode 100644 index 00000000..635b8d4c --- /dev/null +++ b/docker/monitoring/docker-compose.yml @@ -0,0 +1,26 @@ +version: '3.8' + +services: + prometheus: + image: prom/prometheus:latest + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + command: + - --config.file=/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + networks: + - monitoring-network + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + networks: + - monitoring-network + +networks: + monitoring-network: + driver: bridge diff --git a/docker/monitoring/prometheus.yml b/docker/monitoring/prometheus.yml new file mode 100644 index 00000000..9f83897b --- /dev/null +++ b/docker/monitoring/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: prometheus + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8080'] diff --git a/script/deploy.sh b/script/deploy.sh index b6c0e495..aa957ff6 100644 --- a/script/deploy.sh +++ b/script/deploy.sh @@ -1,6 +1,9 @@ #!/bin/bash source .env +NGINX_CONFIG_PATH="/etc/nginx" +ALL_PORT=("8081" "8082") +AVAILABLE_PORT=() REGISTRY_URL=${REGISTRY_URL} IMAGE_NAME=${IMAGE_NAME} SECRET_MANAGER_TOKEN=${SECRET_MANAGER_TOKEN} @@ -9,46 +12,118 @@ TAG="latest" CONTAINER_NAME="smeem" HEALTH_CHECK_URI="/actuator/health" -echo "> Pull docker image" +docker_ps_output=$(sudo docker ps | grep "$CONTAINER_NAME-") +running_container_name=$(echo "$docker_ps_output" | awk '{print $NF}') +blue_port=$(echo "$running_container_name" | awk -F'-' '{print $NF}') + +if [ -z "$blue_port" ]; then + echo "> Running port: none" +else + echo "> Running port: $blue_port" +fi + +# 실행 가능한 포트 확인 +for item in "${ALL_PORT[@]}"; do + if [ "$item" != "$blue_port" ]; then + AVAILABLE_PORT+=("$item") + fi +done + +# 실행 가능한 포트 없으면 끝내기 +if [ ${#AVAILABLE_PORT[@]} -eq 0 ]; then + echo "> No available port" + exit 1 +fi + +green_port=${AVAILABLE_PORT[0]} + +echo "----------------------------------------------------------------------" + +# 도커 이미지 풀 받기 +echo "> Pull Docker Image" sudo docker pull "${REGISTRY_URL}"/"${IMAGE_NAME}":"${TAG}" -echo "> Stop running docker container" -if [ "$(sudo docker ps -a -q -f name=${CONTAINER_NAME})" ]; then - sudo docker stop ${CONTAINER_NAME} - sudo docker rm ${CONTAINER_NAME} +# 그린 포트로 서버 실행 +echo "> Run Docker with ${green_port} port" + +if [ "$(sudo docker ps -a -q -f name=${CONTAINER_NAME}-"${green_port}")" ]; then + docker stop ${CONTAINER_NAME}-"${green_port}" + docker rm ${CONTAINER_NAME}-"${green_port}" fi -echo "> Run docker" -sudo docker run -d --name ${CONTAINER_NAME} -p 80:8080 \ +docker run -d --name ${CONTAINER_NAME}-"${green_port}" -p "${green_port}":8080 \ -e SECRET_MANAGER_TOKEN="${SECRET_MANAGER_TOKEN}" \ -e SECRET_MANAGER_WORKSPACE_ID="${SECRET_MANAGER_WORKSPACE_ID}" \ "${REGISTRY_URL}"/"${IMAGE_NAME}":${TAG} echo "----------------------------------------------------------------------" +# green_port 서버 실행 확인 sleep 15 -for RETRY_COUNT in {1..15} + +for retry_count in {1..15} do echo "> Health check" - RESPONSE=$(curl -s http://localhost${HEALTH_CHECK_URI}) + response=$(curl -s http://localhost:"${green_port}"${HEALTH_CHECK_URI}) # shellcheck disable=SC2126 - UP_COUNT=$(echo "${RESPONSE}" | grep 'UP' | wc -l) + up_count=$(echo "${response}" | grep 'UP' | wc -l) - if [ "${UP_COUNT}" -ge 1 ] + if [ "${up_count}" -ge 1 ] then - echo "> Success" + echo "> SUCCESS" break else echo "> Not run yet" - echo "> 응답 결과: ${RESPONSE}" + echo "> Response: ${response}" fi - if [ "${RETRY_COUNT}" -eq 15 ] + + if [ "${retry_count}" -eq 15 ] then echo "> Failed to running server" - sudo docker rm -f ${CONTAINER_NAME} + docker rm -f ${CONTAINER_NAME}-"${green_port}" exit 1 fi + sleep 2 done + +echo "----------------------------------------------------------------------" + +# Nginx 포트 스위칭 +echo "> Nginx Switching" + +# Nginx 실행 중인지 확인 +if ! pgrep -x "nginx" > /dev/null +then + echo "Start Nginx" + sudo systemctl start nginx +fi + +## 서비스 URL 업데이트 및 Nginx 리로드 +echo "set \$service_url 127.0.0.1:${green_port};" | sudo tee ${NGINX_CONFIG_PATH}/conf.d/service-url.inc +sudo nginx -t && sudo nginx -s reload + +sleep 1 + echo "----------------------------------------------------------------------" + +# Nginx 통해서 서버 접근 가능한지 확인 +response=$(curl -s http://localhost:"${green_port}"${HEALTH_CHECK_URI}) +# shellcheck disable=SC2126 +up_count=$(echo "$response" | grep 'UP' | wc -l) + +if [ "$up_count" -ge 1 ] +then + echo "> Success to Switching" +else + echo "> Failed to Switching" + echo "> Response: ${response}" + exit 1 +fi + +# blue_port 서버 있다면 중단 +if [ -n "$blue_port" ]; then + echo "> Stop ${blue_port} port" + sudo docker rm -f ${CONTAINER_NAME}-"${blue_port}" +fi diff --git a/settings.gradle b/settings.gradle index e0a6020a..318cbef3 100644 --- a/settings.gradle +++ b/settings.gradle @@ -7,6 +7,7 @@ include 'smeem-input-http' include 'smeem-application' include 'smeem-output-persistence' include 'smeem-output-persistence:postgresql' +include 'smeem-output-persistence:mongodb' include 'smeem-output-notification' include 'smeem-output-notification:firebase' include 'smeem-output-oauth' @@ -14,3 +15,5 @@ include 'smeem-output-oauth:apple' include 'smeem-output-oauth:kakao' include 'smeem-output-cache' include 'smeem-output-cache:redis' +include 'smeem-output-web' +include 'smeem-output-web:gpt' diff --git a/smeem-application/src/main/java/com/smeem/application/config/SmeemProperties.java b/smeem-application/src/main/java/com/smeem/application/config/SmeemProperties.java index 992990b4..d23239ef 100644 --- a/smeem-application/src/main/java/com/smeem/application/config/SmeemProperties.java +++ b/smeem-application/src/main/java/com/smeem/application/config/SmeemProperties.java @@ -1,6 +1,9 @@ package com.smeem.application.config; +import com.smeem.common.exception.ExceptionCode; +import com.smeem.common.exception.SmeemException; import jakarta.annotation.PostConstruct; +import lombok.AllArgsConstructor; import lombok.Getter; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -8,17 +11,13 @@ import java.util.Base64; @Getter +@AllArgsConstructor @ConfigurationProperties(prefix = "smeem") public class SmeemProperties { private final Secret secret; private final Duration duration; private final Client client; - - public SmeemProperties(Secret secret, Duration duration, Client client) { - this.secret = secret; - this.duration = duration; - this.client = client; - } + private final Limit limit; @Getter public static class Secret { @@ -58,4 +57,16 @@ public record APP( } } } + + public record Limit( + int correction + ) { + + public void validateCorrectionLimit(int correctionCount) { + if (correctionCount >= correction) { + throw new SmeemException(ExceptionCode.EXCEED_CORRECTION_LIMIT); + } + } + + } } diff --git a/smeem-application/src/main/java/com/smeem/application/domain/auth/AuthService.java b/smeem-application/src/main/java/com/smeem/application/domain/auth/AuthService.java index 64602270..006bca2c 100644 --- a/smeem-application/src/main/java/com/smeem/application/domain/auth/AuthService.java +++ b/smeem-application/src/main/java/com/smeem/application/domain/auth/AuthService.java @@ -7,6 +7,7 @@ import com.smeem.application.port.input.dto.response.auth.GenerateTokenResponse; import com.smeem.application.port.input.dto.response.auth.SignInResponse; import com.smeem.application.port.output.oauth.OauthPort; +import com.smeem.application.port.output.persistence.CorrectionPort; import com.smeem.application.port.output.persistence.MemberPort; import com.smeem.common.logger.HookLogger; import com.smeem.common.logger.LoggingMessage; @@ -19,6 +20,7 @@ @RequiredArgsConstructor @Transactional(readOnly = true) public class AuthService implements AuthUseCase { + private final CorrectionPort correctionPort; private final MemberPort memberPort; private final OauthPort oauthPort; private final TokenGenerator tokenGenerator; @@ -54,7 +56,12 @@ public void signOut(long memberId) { @Transactional public void withdraw(long memberId, WithdrawRequest request) { + correctionPort.deleteByMember(memberId); memberPort.deleteById(memberId); + createWithdrawal(request); + } + + private void createWithdrawal(WithdrawRequest request) { if (request != null) { memberPort.saveWithdraw(request.toDomain()); hookLogger.send(LoggingMessage.withdraw( diff --git a/smeem-application/src/main/java/com/smeem/application/domain/auth/TokenGenerator.java b/smeem-application/src/main/java/com/smeem/application/domain/auth/TokenGenerator.java index 6eb980a1..9fe140f3 100644 --- a/smeem-application/src/main/java/com/smeem/application/domain/auth/TokenGenerator.java +++ b/smeem-application/src/main/java/com/smeem/application/domain/auth/TokenGenerator.java @@ -14,8 +14,8 @@ public class TokenGenerator { private final SecretKeyFactory secretKeyFactory; - private static final Long ACCESS_TOKEN_EXPIRATION_TIME = 60 * 60 * 1000 * 2 * 12 * 1000000L; - private static final Long REFRESH_TOKEN_EXPIRATION_TIME = 60 * 60 * 1000 * 24 * 14L; + private static final Long ACCESS_TOKEN_EXPIRATION_TIME = 60 * 60 * 1000 * 2L; // 2시간 + private static final Long REFRESH_TOKEN_EXPIRATION_TIME = 60 * 60 * 1000 * 24 * 14L; // 2주 public String generateAccessToken(long memberId) { val authentication = UserAuthentication.create(memberId); diff --git a/smeem-application/src/main/java/com/smeem/application/domain/diary/Correction.java b/smeem-application/src/main/java/com/smeem/application/domain/diary/Correction.java new file mode 100644 index 00000000..2baba242 --- /dev/null +++ b/smeem-application/src/main/java/com/smeem/application/domain/diary/Correction.java @@ -0,0 +1,22 @@ +package com.smeem.application.domain.diary; + +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record Correction( + @Schema(description = "교정 전 문장", example = "hallo") + @JsonProperty("original_sentence") + String originalSentence, + @Schema(description = "교정 후 문장", example = "hello") + @JsonProperty("corrected_sentence") + String correctedSentence, + @Schema(description = "교정 사유", example = "스펠링 틀림") + @JsonProperty("reason") + String reason, + @Schema(description = "교정 여부", example = "true") + @JsonProperty("is_corrected") + boolean isCorrected +) { +} diff --git a/smeem-application/src/main/java/com/smeem/application/domain/diary/CorrectionService.java b/smeem-application/src/main/java/com/smeem/application/domain/diary/CorrectionService.java new file mode 100644 index 00000000..1a0c60a3 --- /dev/null +++ b/smeem-application/src/main/java/com/smeem/application/domain/diary/CorrectionService.java @@ -0,0 +1,68 @@ +package com.smeem.application.domain.diary; + +import com.smeem.application.config.SmeemProperties; +import com.smeem.application.port.input.CorrectionUseCase; +import com.smeem.application.port.input.dto.response.diary.CorrectionsResponse; +import com.smeem.application.port.output.cache.CachePort; +import com.smeem.application.port.output.persistence.CorrectionPort; +import com.smeem.application.port.output.persistence.DiaryPort; +import com.smeem.application.port.output.web.openai.OpenAiPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CorrectionService implements CorrectionUseCase { + private final CachePort cachePort; + private final CorrectionPort correctionPort; + private final DiaryPort diaryPort; + private final OpenAiPort openAiPort; + private final SmeemProperties smeemProperties; + + @Transactional + public CorrectionsResponse correctDiary(long memberId, long diaryId) { + LocalDate today = LocalDate.now(); + String key = getCorrectionCacheKey(memberId, today); + + // 제한 횟수 검증 + int correctionCount = getOrUpdateCorrectionCount(key, memberId, today); + smeemProperties.getLimit().validateCorrectionLimit(correctionCount); + + // 일기 소유권 검증 + Diary diary = diaryPort.findById(diaryId); + diary.validateDiaryOwnership(memberId); + + // AI 첨삭 및 캐시 업데이트 + List corrections = createCorrections(diary); + cachePort.incrementInt(key); + + return CorrectionsResponse.of(corrections); + } + + private String getCorrectionCacheKey(long memberId, LocalDate date) { + String today = date.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + return "correction:" + today + ":" + memberId; + } + + private int getOrUpdateCorrectionCount(String key, long memberId, LocalDate date) { + return cachePort.getInt(key) + .orElseGet(() -> updateCacheWithCorrectionCount(key, memberId, date)); + } + + private int updateCacheWithCorrectionCount(String key, long memberId, LocalDate date) { + int count = correctionPort.countDistinctByMemberAndDate(memberId, date); + cachePort.setInt(key, count); + return count; + } + + private List createCorrections(Diary diary) { + List corrections = openAiPort.promptCorrections(diary.getContent()); + return correctionPort.save(corrections, diary); + } +} diff --git a/smeem-application/src/main/java/com/smeem/application/domain/diary/Diary.java b/smeem-application/src/main/java/com/smeem/application/domain/diary/Diary.java index bda851b3..25d512ce 100644 --- a/smeem-application/src/main/java/com/smeem/application/domain/diary/Diary.java +++ b/smeem-application/src/main/java/com/smeem/application/domain/diary/Diary.java @@ -1,8 +1,8 @@ package com.smeem.application.domain.diary; import com.smeem.application.domain.generic.LangType; -import com.smeem.application.domain.member.Member; -import com.smeem.application.domain.topic.Topic; +import com.smeem.common.exception.ExceptionCode; +import com.smeem.common.exception.SmeemException; import lombok.Builder; import lombok.Getter; @@ -11,10 +11,21 @@ @Getter @Builder public class Diary { - Long id; - String content; - LangType targetLang; - Topic topic; - Member member; - LocalDateTime createdAt; + private Long id; + private String content; + private LangType targetLang; + private Long topicId; + private long memberId; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public void validateDiaryOwnership(long memberId) { + if (this.memberId != memberId) { + throw new SmeemException(ExceptionCode.INVALID_MEMBER_AND_DIARY); + } + } + + public boolean isUpdated() { + return updatedAt.isAfter(createdAt); + } } diff --git a/smeem-application/src/main/java/com/smeem/application/domain/diary/DiaryService.java b/smeem-application/src/main/java/com/smeem/application/domain/diary/DiaryService.java index e59340d2..746199c9 100644 --- a/smeem-application/src/main/java/com/smeem/application/domain/diary/DiaryService.java +++ b/smeem-application/src/main/java/com/smeem/application/domain/diary/DiaryService.java @@ -4,11 +4,13 @@ import com.smeem.application.domain.badge.Badge; import com.smeem.application.domain.badge.BadgeType; import com.smeem.application.domain.member.Member; +import com.smeem.application.domain.topic.Topic; import com.smeem.application.port.input.DiaryUseCase; import com.smeem.application.port.input.dto.request.diary.WriteDiaryRequest; import com.smeem.application.port.input.dto.response.diary.RetrieveDiariesResponse; import com.smeem.application.port.input.dto.response.diary.RetrieveDiaryResponse; import com.smeem.application.port.input.dto.response.diary.WriteDiaryResponse; +import com.smeem.application.port.output.cache.CachePort; import com.smeem.application.port.output.persistence.*; import lombok.RequiredArgsConstructor; import lombok.val; @@ -16,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; @@ -23,18 +26,22 @@ @Transactional(readOnly = true) @RequiredArgsConstructor public class DiaryService implements DiaryUseCase { + private final CorrectionPort correctionPort; private final DiaryPort diaryPort; private final MemberPort memberPort; private final BadgePort badgePort; private final MemberBadgePort memberBadgePort; private final TopicPort topicPort; private final SmeemProperties smeemProperties; + private final CachePort cachePort; @Transactional public WriteDiaryResponse writeDiary(long memberId, WriteDiaryRequest request) { - val member = memberPort.findById(memberId); - val topic = request.topicId() != null ? topicPort.findById(request.topicId()) : null; - val savedDiary = diaryPort.save(request.toDomain(member, topic)); + Member member = memberPort.findById(memberId); + if (request.topicId() != null) { + topicPort.checkValidation(request.topicId()); + } + Diary savedDiary = diaryPort.save(request.toDomain(member)); val diaryWrittenYesterday = diaryPort.isExistByMemberAndYesterday(memberId); memberPort.update(member.updateDiaryComboCount(diaryWrittenYesterday)); @@ -60,18 +67,38 @@ private List acquireBadgeOfWritingDiary(Member member) { } public RetrieveDiaryResponse retrieveDiary(long diaryId) { - return RetrieveDiaryResponse.of(diaryPort.findByIdJoinMemberAndTopic(diaryId)); + Diary diary = diaryPort.findById(diaryId); + Topic topic = diary.getTopicId() != null ? topicPort.findById(diary.getTopicId()) : null; + Member member = memberPort.findById(diary.getMemberId()); + List corrections = correctionPort.findByDiary(diaryId); + int correctionCount = getCorrectionCount(member.getId()); + return RetrieveDiaryResponse.of(diary, topic, member, corrections, correctionCount); + } + + private int getCorrectionCount(long memberId) { + LocalDate now = LocalDate.now(); + String today = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + String key = "correction:" + today + ":" + memberId; + return cachePort.getInt(key) + .orElseGet(() -> { + int count = correctionPort.countDistinctByMemberAndDate(memberId, now); + cachePort.setInt(key, count); + return count; + }); } @Transactional - public void modifyDiary(long diaryId, WriteDiaryRequest request) { - val foundDiary = diaryPort.findById(diaryId); + public void modifyDiary(long memberId, long diaryId, WriteDiaryRequest request) { + Diary foundDiary = diaryPort.findById(diaryId); + foundDiary.validateDiaryOwnership(memberId); diaryPort.update(request.toDomain(foundDiary)); + correctionPort.deleteByDiary(diaryId); } @Transactional - public void deleteDiary(long diary) { - diaryPort.softDelete(diary); + public void deleteDiary(long diaryId) { + diaryPort.softDelete(diaryId); + correctionPort.deleteByDiary(diaryId); } public RetrieveDiariesResponse retrieveDiariesByTerm(long memberId, LocalDate startDate, LocalDate endDate) { diff --git a/smeem-application/src/main/java/com/smeem/application/domain/generic/SmeemMessage.java b/smeem-application/src/main/java/com/smeem/application/domain/generic/SmeemMessage.java index a64c3ce9..80639ed3 100644 --- a/smeem-application/src/main/java/com/smeem/application/domain/generic/SmeemMessage.java +++ b/smeem-application/src/main/java/com/smeem/application/domain/generic/SmeemMessage.java @@ -18,6 +18,7 @@ public class SmeemMessage { public static final String MODIFY_DIARY = "일기 수정 성공"; public static final String RETRIEVE_DIARY = "일기 조회 성공"; public static final String DELETE_MESSAGE = "일기 삭제 성공"; + public static final String COACH_DIARY = "일기 코칭 성공"; // goal public static final String RETRIEVE_GOAL = "목표 조회 성공"; diff --git a/smeem-application/src/main/java/com/smeem/application/domain/member/MemberService.java b/smeem-application/src/main/java/com/smeem/application/domain/member/MemberService.java index 5b8e53e7..737cc8e4 100644 --- a/smeem-application/src/main/java/com/smeem/application/domain/member/MemberService.java +++ b/smeem-application/src/main/java/com/smeem/application/domain/member/MemberService.java @@ -37,6 +37,7 @@ public class MemberService implements MemberUseCase { private final PlanPort planPort; private final HookLogger hookLogger; private final CachePort cachePort; + private final VisitPort visitPort; @Transactional public UpdateMemberResponse updateMember(long memberId, UpdateMemberRequest request) { @@ -90,16 +91,18 @@ public RetrievePerformanceResponse retrieveMemberPerformance(long memberId) { badgePort.countByMember(memberId)); } + //TODO: redis 죽으면? @Transactional public void visit(long memberId) { Member foundMember = memberPort.findById(memberId); - String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")); - String key = "visit:" + today; + LocalDate today = LocalDate.now(); + String key = "visit:" + today.format(DateTimeFormatter.ofPattern("yyyyMMdd")); if (!cachePort.getBit(key, foundMember.getId())) { foundMember.visit(); memberPort.update(foundMember); cachePort.setBit(key, foundMember.getId(), true); + visitPort.update(today, cachePort.getBitmap(key)); } } diff --git a/smeem-application/src/main/java/com/smeem/application/port/input/CorrectionUseCase.java b/smeem-application/src/main/java/com/smeem/application/port/input/CorrectionUseCase.java new file mode 100644 index 00000000..3571436c --- /dev/null +++ b/smeem-application/src/main/java/com/smeem/application/port/input/CorrectionUseCase.java @@ -0,0 +1,7 @@ +package com.smeem.application.port.input; + +import com.smeem.application.port.input.dto.response.diary.CorrectionsResponse; + +public interface CorrectionUseCase { + CorrectionsResponse correctDiary(long memberId, long diaryId); +} diff --git a/smeem-application/src/main/java/com/smeem/application/port/input/DiaryUseCase.java b/smeem-application/src/main/java/com/smeem/application/port/input/DiaryUseCase.java index 4af2f4e8..2494e6fa 100644 --- a/smeem-application/src/main/java/com/smeem/application/port/input/DiaryUseCase.java +++ b/smeem-application/src/main/java/com/smeem/application/port/input/DiaryUseCase.java @@ -10,7 +10,7 @@ public interface DiaryUseCase { WriteDiaryResponse writeDiary(long memberId, WriteDiaryRequest request); RetrieveDiaryResponse retrieveDiary(long diaryId); - void modifyDiary(long diaryId, WriteDiaryRequest request); + void modifyDiary(long memberId, long diaryId, WriteDiaryRequest request); void deleteDiary(long diary); RetrieveDiariesResponse retrieveDiariesByTerm(long memberId, LocalDate startDate, LocalDate endDate); void deleteExpiredDiaries(int duration); diff --git a/smeem-application/src/main/java/com/smeem/application/port/input/dto/request/diary/WriteDiaryRequest.java b/smeem-application/src/main/java/com/smeem/application/port/input/dto/request/diary/WriteDiaryRequest.java index cebcbfab..da0e4bc0 100644 --- a/smeem-application/src/main/java/com/smeem/application/port/input/dto/request/diary/WriteDiaryRequest.java +++ b/smeem-application/src/main/java/com/smeem/application/port/input/dto/request/diary/WriteDiaryRequest.java @@ -2,7 +2,6 @@ import com.smeem.application.domain.diary.Diary; import com.smeem.application.domain.member.Member; -import com.smeem.application.domain.topic.Topic; import io.swagger.v3.oas.annotations.media.Schema; import lombok.NonNull; @@ -14,21 +13,21 @@ public record WriteDiaryRequest( Long topicId ) { - public Diary toDomain(Member member, Topic topic) { + public Diary toDomain(Member member) { return Diary.builder() .content(content) .targetLang(member.getTargetLang()) - .topic(topic) - .member(member) + .topicId(topicId) + .memberId(member.getId()) .build(); } - public Diary toDomain(Diary diary) { + public Diary toDomain(Diary originDiary) { return Diary.builder() - .id(diary.getId()) .content(content) - .targetLang(diary.getTargetLang()) - .topic(Topic.builder().id(topicId).build()) + .targetLang(originDiary.getTargetLang()) + .topicId(topicId) + .memberId(originDiary.getMemberId()) .build(); } } diff --git a/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/CorrectionResponse.java b/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/CorrectionResponse.java new file mode 100644 index 00000000..2fbb0dd8 --- /dev/null +++ b/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/CorrectionResponse.java @@ -0,0 +1,28 @@ +package com.smeem.application.port.input.dto.response.diary; + +import com.smeem.application.domain.diary.Correction; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PRIVATE) +public record CorrectionResponse( + @Schema(description = "교정 전 문장", example = "hallo") + String originalSentence, + @Schema(description = "교정 후 문장", example = "hello") + String correctedSentence, + @Schema(description = "교정 사유", example = "스펠링 틀림") + String reason, + @Schema(description = "교정 여부", example = "true") + boolean isCorrected +) { + + public static CorrectionResponse from(Correction correction) { + return CorrectionResponse.builder() + .originalSentence(correction.originalSentence()) + .correctedSentence(correction.correctedSentence()) + .reason(correction.reason()) + .isCorrected(correction.isCorrected()) + .build(); + } +} diff --git a/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/CorrectionsResponse.java b/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/CorrectionsResponse.java new file mode 100644 index 00000000..8c3d1699 --- /dev/null +++ b/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/CorrectionsResponse.java @@ -0,0 +1,21 @@ +package com.smeem.application.port.input.dto.response.diary; + +import com.smeem.application.domain.diary.Correction; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; + +import java.util.List; + +@Builder(access = AccessLevel.PRIVATE) +public record CorrectionsResponse( + @Schema(description = "코칭 결과") + List corrections +) { + + public static CorrectionsResponse of(List corrections) { + return CorrectionsResponse.builder() + .corrections(corrections.stream().map(CorrectionResponse::from).toList()) + .build(); + } +} diff --git a/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/RetrieveDiaryResponse.java b/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/RetrieveDiaryResponse.java index 144f700d..d41f4e97 100644 --- a/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/RetrieveDiaryResponse.java +++ b/smeem-application/src/main/java/com/smeem/application/port/input/dto/response/diary/RetrieveDiaryResponse.java @@ -1,10 +1,16 @@ package com.smeem.application.port.input.dto.response.diary; +import com.smeem.application.domain.diary.Correction; import com.smeem.application.domain.diary.Diary; +import com.smeem.application.domain.member.Member; +import com.smeem.application.domain.topic.Topic; import com.smeem.common.util.SmeemConverter; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; import lombok.Builder; +import java.util.List; + import static lombok.AccessLevel.PRIVATE; @Builder(access = PRIVATE) @@ -18,18 +24,34 @@ public record RetrieveDiaryResponse( @Schema(description = "일기 작성일") String createdAt, @Schema(description = "일기 작성자 닉네임") - String username + String username, + @Schema(description = "일기 수정 여부") + boolean isUpdated, + @Schema(description = "코칭 결과 정보") + List corrections, + @Schema(description = "코칭 횟수") + int correctionCount, + @Schema(description = "코칭 최대 횟수") + int correctionMaxCount ) { - - public static RetrieveDiaryResponse of(Diary diary) { + public static RetrieveDiaryResponse of( + @NotNull Diary diary, + Topic topic, + @NotNull Member member, + @NotNull List corrections, + int correctionCount + ) { return RetrieveDiaryResponse.builder() .diaryId(diary.getId()) - .topic(diary.getTopic() != null ? diary.getTopic().getContent() : "") + .topic(topic != null ? topic.getContent() : "") .content(diary.getContent()) .createdAt(SmeemConverter.toString(diary.getCreatedAt())) - .username(diary.getMember().getUsername()) + .username(member.getUsername()) + .isUpdated(diary.isUpdated()) + .corrections(corrections.stream().map(CorrectionResponse::from).toList()) + .correctionCount(correctionCount) + .correctionMaxCount(1) .build(); } - } diff --git a/smeem-application/src/main/java/com/smeem/application/port/output/cache/CachePort.java b/smeem-application/src/main/java/com/smeem/application/port/output/cache/CachePort.java index f5d500e9..7c99d468 100644 --- a/smeem-application/src/main/java/com/smeem/application/port/output/cache/CachePort.java +++ b/smeem-application/src/main/java/com/smeem/application/port/output/cache/CachePort.java @@ -1,6 +1,13 @@ package com.smeem.application.port.output.cache; +import java.util.Optional; + public interface CachePort { void setBit(String key, long offset, boolean value); boolean getBit(String key, long offset); + String getBitmap(String key); + + Optional getInt(String key); + void setInt(String key, int value); + void incrementInt(String key); } diff --git a/smeem-application/src/main/java/com/smeem/application/port/output/persistence/CorrectionPort.java b/smeem-application/src/main/java/com/smeem/application/port/output/persistence/CorrectionPort.java new file mode 100644 index 00000000..737a8178 --- /dev/null +++ b/smeem-application/src/main/java/com/smeem/application/port/output/persistence/CorrectionPort.java @@ -0,0 +1,19 @@ +package com.smeem.application.port.output.persistence; + +import com.smeem.application.domain.diary.Correction; +import com.smeem.application.domain.diary.Diary; + +import java.time.LocalDate; +import java.util.List; + +public interface CorrectionPort { + List save(List corrections, Diary diary); + + int countDistinctByMemberAndDate(long memberId, LocalDate date); + + List findByDiary(long diary); + + void deleteByDiary(long diaryId); + + void deleteByMember(long memberId); +} diff --git a/smeem-application/src/main/java/com/smeem/application/port/output/persistence/DiaryPort.java b/smeem-application/src/main/java/com/smeem/application/port/output/persistence/DiaryPort.java index 2ca8cc8a..c1de56ff 100644 --- a/smeem-application/src/main/java/com/smeem/application/port/output/persistence/DiaryPort.java +++ b/smeem-application/src/main/java/com/smeem/application/port/output/persistence/DiaryPort.java @@ -9,7 +9,6 @@ public interface DiaryPort { int countByMember(long memberId); Diary save(Diary diary); boolean isExistByMemberAndYesterday(long memberId); - Diary findByIdJoinMemberAndTopic(long id); Diary findById(long id); Diary update(Diary diary); void softDelete(long id); diff --git a/smeem-application/src/main/java/com/smeem/application/port/output/persistence/TopicPort.java b/smeem-application/src/main/java/com/smeem/application/port/output/persistence/TopicPort.java index 88966130..e9712bbe 100644 --- a/smeem-application/src/main/java/com/smeem/application/port/output/persistence/TopicPort.java +++ b/smeem-application/src/main/java/com/smeem/application/port/output/persistence/TopicPort.java @@ -5,4 +5,6 @@ public interface TopicPort { Topic findById(long id); Topic findRandom(); + + void checkValidation(long topicId); } diff --git a/smeem-application/src/main/java/com/smeem/application/port/output/persistence/VisitPort.java b/smeem-application/src/main/java/com/smeem/application/port/output/persistence/VisitPort.java new file mode 100644 index 00000000..1bec0834 --- /dev/null +++ b/smeem-application/src/main/java/com/smeem/application/port/output/persistence/VisitPort.java @@ -0,0 +1,7 @@ +package com.smeem.application.port.output.persistence; + +import java.time.LocalDate; + +public interface VisitPort { + void update(LocalDate date, String bitmap); +} diff --git a/smeem-application/src/main/java/com/smeem/application/port/output/web/openai/OpenAiPort.java b/smeem-application/src/main/java/com/smeem/application/port/output/web/openai/OpenAiPort.java new file mode 100644 index 00000000..178dcfb3 --- /dev/null +++ b/smeem-application/src/main/java/com/smeem/application/port/output/web/openai/OpenAiPort.java @@ -0,0 +1,10 @@ +package com.smeem.application.port.output.web.openai; + +import com.smeem.application.domain.diary.Correction; + +import java.util.List; + +public interface OpenAiPort { + String prompt(String message); + List promptCorrections(String content); +} diff --git a/smeem-application/src/main/resources/smeem-config/application-dev.yml b/smeem-application/src/main/resources/smeem-config/application-dev.yml index f9bf4824..4079f6f2 100644 --- a/smeem-application/src/main/resources/smeem-config/application-dev.yml +++ b/smeem-application/src/main/resources/smeem-config/application-dev.yml @@ -13,3 +13,5 @@ smeem: android: app: ${CLIENT_VERSION_ANDROID_APP} force: ${CLIENT_VERSION_ANDROID_FORCE} + limit: + correction: ${SMEEM_CORRECTION_LIMIT_COUNT} diff --git a/smeem-application/src/main/resources/smeem-config/application-local.yml b/smeem-application/src/main/resources/smeem-config/application-local.yml index f9bf4824..4079f6f2 100644 --- a/smeem-application/src/main/resources/smeem-config/application-local.yml +++ b/smeem-application/src/main/resources/smeem-config/application-local.yml @@ -13,3 +13,5 @@ smeem: android: app: ${CLIENT_VERSION_ANDROID_APP} force: ${CLIENT_VERSION_ANDROID_FORCE} + limit: + correction: ${SMEEM_CORRECTION_LIMIT_COUNT} diff --git a/smeem-application/src/main/resources/smeem-config/application-prod.yml b/smeem-application/src/main/resources/smeem-config/application-prod.yml index f9bf4824..4079f6f2 100644 --- a/smeem-application/src/main/resources/smeem-config/application-prod.yml +++ b/smeem-application/src/main/resources/smeem-config/application-prod.yml @@ -13,3 +13,5 @@ smeem: android: app: ${CLIENT_VERSION_ANDROID_APP} force: ${CLIENT_VERSION_ANDROID_FORCE} + limit: + correction: ${SMEEM_CORRECTION_LIMIT_COUNT} diff --git a/smeem-bootstrap/build.gradle b/smeem-bootstrap/build.gradle index 14c6ab5e..09cb101d 100644 --- a/smeem-bootstrap/build.gradle +++ b/smeem-bootstrap/build.gradle @@ -4,6 +4,7 @@ dependencies { implementation project(':smeem-input-http') implementation project(':smeem-output-persistence') implementation project(':smeem-output-persistence:postgresql') + implementation project(':smeem-output-persistence:mongodb') implementation project(':smeem-output-notification') implementation project(':smeem-output-notification:firebase') implementation project(':smeem-output-oauth') @@ -11,9 +12,12 @@ dependencies { implementation project(':smeem-output-oauth:kakao') implementation project(':smeem-output-cache') implementation project(':smeem-output-cache:redis') + implementation project(':smeem-output-web') + implementation project(':smeem-output-web:gpt') implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-actuator' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' } tasks.bootJar { diff --git a/smeem-bootstrap/src/main/resources/application-actuator.yml b/smeem-bootstrap/src/main/resources/application-actuator.yml new file mode 100644 index 00000000..264baaf1 --- /dev/null +++ b/smeem-bootstrap/src/main/resources/application-actuator.yml @@ -0,0 +1,5 @@ +management: + endpoints: + web: + exposure: + include: info, health, metrics, prometheus diff --git a/smeem-bootstrap/src/main/resources/application-dev.yml b/smeem-bootstrap/src/main/resources/application-dev.yml index 84548e8e..06b783d4 100644 --- a/smeem-bootstrap/src/main/resources/application-dev.yml +++ b/smeem-bootstrap/src/main/resources/application-dev.yml @@ -4,6 +4,7 @@ spring: on-profile: dev import: - classpath:postgres-config/application-dev.yml + - classpath:mongo-config/application-dev.yml - classpath:smeem-config/application-dev.yml - classpath:notification-config/application-dev.yml - classpath:common-config/application-dev.yml diff --git a/smeem-bootstrap/src/main/resources/application-local.yml b/smeem-bootstrap/src/main/resources/application-local.yml index c4efcc01..85fc1df1 100644 --- a/smeem-bootstrap/src/main/resources/application-local.yml +++ b/smeem-bootstrap/src/main/resources/application-local.yml @@ -4,6 +4,7 @@ spring: on-profile: local import: - classpath:postgres-config/application-local.yml + - classpath:mongo-config/application-local.yml - classpath:smeem-config/application-local.yml - classpath:notification-config/application-local.yml - classpath:common-config/application-local.yml diff --git a/smeem-bootstrap/src/main/resources/application-prod.yml b/smeem-bootstrap/src/main/resources/application-prod.yml index bf3eb50b..49c558b8 100644 --- a/smeem-bootstrap/src/main/resources/application-prod.yml +++ b/smeem-bootstrap/src/main/resources/application-prod.yml @@ -4,6 +4,7 @@ spring: on-profile: prod import: - classpath:postgres-config/application-prod.yml + - classpath:mongo-config/application-prod.yml - classpath:smeem-config/application-prod.yml - classpath:notification-config/application-prod.yml - classpath:common-config/application-prod.yml diff --git a/smeem-bootstrap/src/main/resources/application.yml b/smeem-bootstrap/src/main/resources/application.yml index 3d60f036..ab121b2b 100644 --- a/smeem-bootstrap/src/main/resources/application.yml +++ b/smeem-bootstrap/src/main/resources/application.yml @@ -1,8 +1,10 @@ spring: config: import: + - classpath:application-actuator.yml - classpath:oauth-apple-config/application.yml - classpath:oauth-kakao-config/application.yml + - classpath:gpt-config/application.yml application: name: "smeem" messages: diff --git a/smeem-common/src/main/java/com/smeem/common/exception/ExceptionCode.java b/smeem-common/src/main/java/com/smeem/common/exception/ExceptionCode.java index 2f4e95ac..2ba92b01 100644 --- a/smeem-common/src/main/java/com/smeem/common/exception/ExceptionCode.java +++ b/smeem-common/src/main/java/com/smeem/common/exception/ExceptionCode.java @@ -7,13 +7,17 @@ @Getter public enum ExceptionCode { // 4xx + EXCEED_CORRECTION_LIMIT(400, "코칭 기능 가능 횟수를 초과했습니다."), + INVALID_MEMBER_AND_DIARY(400, "회원의 일기가 아닙니다."), UNAUTHORIZED(401, "유효하지 않은 토큰 "), NOT_FOUND(404, "존재하지 않음 "), TOO_MANY_REQUESTS(429, "너무 많은 요청"), // 5xx SERVICE_AVAILABLE(503, "서비스에 접근할 수 없음 "), + OPEN_AI_SERVICE_AVAILABLE(503, "OpenAI 서비스 에러"), INTERNAL_SERVER_ERROR(500, "서버 내부 오류"), + JSON_PARSE_ERROR(503, "json 파싱 오류. 서버에게 문의해주세요."), ; private final int statusCode; diff --git a/smeem-common/src/main/java/com/smeem/common/exception/SmeemException.java b/smeem-common/src/main/java/com/smeem/common/exception/SmeemException.java index 66ea2513..06a6b83f 100644 --- a/smeem-common/src/main/java/com/smeem/common/exception/SmeemException.java +++ b/smeem-common/src/main/java/com/smeem/common/exception/SmeemException.java @@ -18,4 +18,11 @@ public SmeemException(ExceptionCode exceptionCode, String message) { this.defaultMessage = exceptionCode.getMessage(); this.detailMessage = message; } + + public String getMessage() { + if (detailMessage != null) { + return "[" + defaultMessage + "] " + detailMessage; + } + return defaultMessage; + } } diff --git a/smeem-input-http/src/main/java/com/smeem/http/config/SecurityConfig.java b/smeem-input-http/src/main/java/com/smeem/http/config/SecurityConfig.java index 3a5b37a0..a0415f60 100644 --- a/smeem-input-http/src/main/java/com/smeem/http/config/SecurityConfig.java +++ b/smeem-input-http/src/main/java/com/smeem/http/config/SecurityConfig.java @@ -48,7 +48,7 @@ private void setHttp(HttpSecurity http) throws Exception { authorizeHttpRequests .requestMatchers(new AntPathRequestMatcher("/api/v2/auth", "POST")).permitAll() .requestMatchers(new AntPathRequestMatcher("/actuator/health")).permitAll() - .requestMatchers(new AntPathRequestMatcher("/api/v2/test")).permitAll() //TODO: 삭제 + .requestMatchers(new AntPathRequestMatcher("/api/v2/test")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/v2/versions/client/app")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/v2/goals/{type}")).permitAll() .requestMatchers(new AntPathRequestMatcher("/api/v2/goals")).permitAll() @@ -56,6 +56,7 @@ private void setHttp(HttpSecurity http) throws Exception { .requestMatchers(new AntPathRequestMatcher("/api/v2/plans")).permitAll() .requestMatchers(new AntPathRequestMatcher("/favicon.ico")).permitAll() .requestMatchers(new AntPathRequestMatcher("/error")).permitAll() + .requestMatchers(new AntPathRequestMatcher("/actuator/**")).permitAll() .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); } diff --git a/smeem-input-http/src/main/java/com/smeem/http/controller/CorrectionApi.java b/smeem-input-http/src/main/java/com/smeem/http/controller/CorrectionApi.java new file mode 100644 index 00000000..810bc567 --- /dev/null +++ b/smeem-input-http/src/main/java/com/smeem/http/controller/CorrectionApi.java @@ -0,0 +1,29 @@ +package com.smeem.http.controller; + +import com.smeem.application.domain.generic.SmeemMessage; +import com.smeem.application.port.input.CorrectionUseCase; +import com.smeem.application.port.input.dto.response.diary.CorrectionsResponse; +import com.smeem.common.util.SmeemConverter; +import com.smeem.http.controller.docs.CorrectionApiDocs; +import com.smeem.http.controller.dto.SmeemResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.security.Principal; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/diaries/{diaryId}/corrections") +public class CorrectionApi implements CorrectionApiDocs { + private final SmeemConverter smeemConverter; + private final CorrectionUseCase correctionUseCase; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public SmeemResponse correctDiary(Principal principal, @PathVariable long diaryId) { + long memberId = smeemConverter.toMemberId(principal); + CorrectionsResponse response = correctionUseCase.correctDiary(memberId, diaryId); + return SmeemResponse.of(response, SmeemMessage.COACH_DIARY); + } +} diff --git a/smeem-input-http/src/main/java/com/smeem/http/controller/DiaryApi.java b/smeem-input-http/src/main/java/com/smeem/http/controller/DiaryApi.java index a47a34ea..2522f6c9 100644 --- a/smeem-input-http/src/main/java/com/smeem/http/controller/DiaryApi.java +++ b/smeem-input-http/src/main/java/com/smeem/http/controller/DiaryApi.java @@ -44,8 +44,13 @@ public SmeemResponse retrieveDiary(@PathVariable long dia @ResponseStatus(HttpStatus.OK) @PatchMapping("/{diaryId}") - public SmeemResponse modifyDiary(@PathVariable long diaryId, @RequestBody WriteDiaryRequest request) { - diaryUseCase.modifyDiary(diaryId, request); + public SmeemResponse modifyDiary( + Principal principal, + @PathVariable long diaryId, + @RequestBody WriteDiaryRequest request + ) { + long memberId = smeemConverter.toMemberId(principal); + diaryUseCase.modifyDiary(memberId, diaryId, request); return SmeemResponse.of(SmeemMessage.MODIFY_DIARY); } diff --git a/smeem-input-http/src/main/java/com/smeem/http/controller/docs/CorrectionApiDocs.java b/smeem-input-http/src/main/java/com/smeem/http/controller/docs/CorrectionApiDocs.java new file mode 100644 index 00000000..501ee21f --- /dev/null +++ b/smeem-input-http/src/main/java/com/smeem/http/controller/docs/CorrectionApiDocs.java @@ -0,0 +1,35 @@ +package com.smeem.http.controller.docs; + +import com.smeem.application.port.input.dto.response.diary.CorrectionsResponse; +import com.smeem.http.controller.dto.SmeemResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.security.Principal; + +@Tag(name = "[Correction] 코칭", description = "코칭 기능 관련 API") +public interface CorrectionApiDocs { + + @Operation(summary = "일기 코칭 api", description = "AI 코칭 실행 및 결과 조회") + @ApiResponses(value = { + @ApiResponse( + responseCode = "201", + description = "CREATED success", + content = @Content(schema = @Schema(implementation = CorrectionsResponse.class))) + }) + SmeemResponse correctDiary( + @Parameter(hidden = true) Principal principal, + @Parameter( + name = "diaryId", + description = "코칭할 일기 id", + required = true, + in = ParameterIn.PATH + ) long diaryId + ); +} diff --git a/smeem-input-http/src/main/java/com/smeem/http/controller/docs/DiaryApiDocs.java b/smeem-input-http/src/main/java/com/smeem/http/controller/docs/DiaryApiDocs.java index 5d3764f3..55820860 100644 --- a/smeem-input-http/src/main/java/com/smeem/http/controller/docs/DiaryApiDocs.java +++ b/smeem-input-http/src/main/java/com/smeem/http/controller/docs/DiaryApiDocs.java @@ -59,6 +59,7 @@ SmeemResponse retrieveDiary( description = "OK success") }) SmeemResponse modifyDiary( + @Parameter(hidden = true) Principal principal, @Parameter( name = "diaryId", description = "수정할 일기 id", diff --git a/smeem-output-cache/redis/src/main/java/com/smeem/output/cache/redis/adapter/CacheAdapter.java b/smeem-output-cache/redis/src/main/java/com/smeem/output/cache/redis/adapter/CacheAdapter.java deleted file mode 100644 index 0bc1b77f..00000000 --- a/smeem-output-cache/redis/src/main/java/com/smeem/output/cache/redis/adapter/CacheAdapter.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.smeem.output.cache.redis.adapter; - -import com.smeem.application.port.output.cache.CachePort; -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.core.RedisTemplate; - -@Configuration -@RequiredArgsConstructor -public class CacheAdapter implements CachePort { - private final RedisTemplate redisTemplate; - - @Override - public void setBit(String key, long offset, boolean value) { - redisTemplate.opsForValue().setBit(key, offset, value); - } - - @Override - public boolean getBit(String key, long offset) { - return Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, offset)); - } -} diff --git a/smeem-output-cache/redis/src/main/java/com/smeem/output/cache/redis/adapter/RedisCacheAdapter.java b/smeem-output-cache/redis/src/main/java/com/smeem/output/cache/redis/adapter/RedisCacheAdapter.java new file mode 100644 index 00000000..76f79c4d --- /dev/null +++ b/smeem-output-cache/redis/src/main/java/com/smeem/output/cache/redis/adapter/RedisCacheAdapter.java @@ -0,0 +1,48 @@ +package com.smeem.output.cache.redis.adapter; + +import com.smeem.application.port.output.cache.CachePort; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.Arrays; +import java.util.Optional; + +//TODO: Redis 죽으면 어떻게 할 것인가? +@Configuration +@RequiredArgsConstructor +public class RedisCacheAdapter implements CachePort { + private final RedisTemplate redisTemplate; + private final RedisTemplate integerRedisTemplate; + + @Override + public void setBit(String key, long offset, boolean value) { + redisTemplate.opsForValue().setBit(key, offset, value); + } + + @Override + public boolean getBit(String key, long offset) { + return Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(key, offset)); + } + + @Override + public String getBitmap(String key) { + byte[] arr = redisTemplate.execute(it -> it.stringCommands().get(key.getBytes()), true); + return Arrays.toString(arr); + } + + @Override + public Optional getInt(String key) { + return Optional.ofNullable(integerRedisTemplate.opsForValue().get(key)); + } + + @Override + public void setInt(String key, int value) { + integerRedisTemplate.opsForValue().set(key, value); + } + + @Override + public void incrementInt(String key) { + redisTemplate.opsForValue().increment(key); + } +} diff --git a/smeem-output-persistence/build.gradle b/smeem-output-persistence/build.gradle index 61245d57..c0b90e2e 100644 --- a/smeem-output-persistence/build.gradle +++ b/smeem-output-persistence/build.gradle @@ -1,6 +1,8 @@ project(':smeem-output-persistence:postgresql') { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // postgresql implementation 'org.postgresql:postgresql' // QueryDSL @@ -11,6 +13,13 @@ project(':smeem-output-persistence:postgresql') { } } +project(':smeem-output-persistence:mongodb') { + dependencies { + // mongodb + implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' + } +} + allprojects { dependencies { implementation project(':smeem-common') diff --git a/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/adapter/CorrectionAdapter.java b/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/adapter/CorrectionAdapter.java new file mode 100644 index 00000000..1a8c76a2 --- /dev/null +++ b/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/adapter/CorrectionAdapter.java @@ -0,0 +1,50 @@ +package com.smeem.output.persistence.mongodb.adapter; + +import com.smeem.application.domain.diary.Correction; +import com.smeem.application.domain.diary.Diary; +import com.smeem.application.port.output.persistence.CorrectionPort; +import com.smeem.output.persistence.mongodb.persistence.document.CorrectionDocument; +import com.smeem.output.persistence.mongodb.persistence.repository.CorrectionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class CorrectionAdapter implements CorrectionPort { + private final CorrectionRepository correctionRepository; + + @Override + public List save(List corrections, Diary diary) { + CorrectionDocument savedCorrection = correctionRepository.save(new CorrectionDocument(corrections, diary)); + return savedCorrection.toDomain(); + } + + @Override + public int countDistinctByMemberAndDate(long memberId, LocalDate date) { + LocalDateTime startOfDay = date.atStartOfDay(); + LocalDateTime endOfDay = date.plusDays(1).atStartOfDay(); + return correctionRepository.countByMemberIdAndCreatedAtBetween(memberId, startOfDay, endOfDay); + } + + @Override + public List findByDiary(long diaryId) { + return correctionRepository.findByDiaryId(diaryId) + .map(CorrectionDocument::toDomain) + .orElseGet(ArrayList::new); + } + + @Override + public void deleteByDiary(long diaryId) { + correctionRepository.deleteByDiaryId(diaryId); + } + + @Override + public void deleteByMember(long memberId) { + correctionRepository.deleteByMemberId(memberId); + } +} diff --git a/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/config/MongoConfig.java b/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/config/MongoConfig.java new file mode 100644 index 00000000..2ed417a5 --- /dev/null +++ b/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/config/MongoConfig.java @@ -0,0 +1,29 @@ +package com.smeem.output.persistence.mongodb.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.config.EnableMongoAuditing; +import org.springframework.data.mongodb.core.convert.DbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultDbRefResolver; +import org.springframework.data.mongodb.core.convert.DefaultMongoTypeMapper; +import org.springframework.data.mongodb.core.convert.MappingMongoConverter; +import org.springframework.data.mongodb.core.mapping.MongoMappingContext; +import org.springframework.data.mongodb.repository.config.EnableMongoRepositories; + +@Configuration +@EnableMongoAuditing +@EnableMongoRepositories(basePackages = "com.smeem.output.persistence.mongodb") +public class MongoConfig { + + @Bean + public MappingMongoConverter mappingMongoConverter( + MongoDatabaseFactory mongoDatabaseFactory, + MongoMappingContext mongoMappingContext + ) { + DbRefResolver dbRefResolver = new DefaultDbRefResolver(mongoDatabaseFactory); + MappingMongoConverter converter = new MappingMongoConverter(dbRefResolver, mongoMappingContext); + converter.setTypeMapper(new DefaultMongoTypeMapper(null)); + return converter; + } +} diff --git a/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/persistence/document/CorrectionDocument.java b/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/persistence/document/CorrectionDocument.java new file mode 100644 index 00000000..80fa78c6 --- /dev/null +++ b/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/persistence/document/CorrectionDocument.java @@ -0,0 +1,61 @@ +package com.smeem.output.persistence.mongodb.persistence.document; + +import com.smeem.application.domain.diary.Correction; +import com.smeem.application.domain.diary.Diary; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.List; + +@Document(collection = "correction") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CorrectionDocument { + @Id + private String id; + private List corrections; + private long memberId; + private long diaryId; + @CreatedDate + private LocalDateTime createdAt; + + @NoArgsConstructor(access = AccessLevel.PROTECTED) + @Getter + private static class CorrectionDetail { + private String originContent; + private String correctedContent; + private String reason; + private boolean corrected; + + public CorrectionDetail(Correction correction) { + this.originContent = correction.originalSentence(); + this.correctedContent = correction.correctedSentence(); + this.corrected = correction.isCorrected(); + this.reason = corrected ? correction.reason() : null; + } + + public Correction toDomain() { + return Correction.builder() + .originalSentence(originContent) + .correctedSentence(correctedContent) + .reason(reason) + .isCorrected(corrected) + .build(); + } + } + + public CorrectionDocument(List corrections, Diary diary) { + this.corrections = corrections.stream().map(CorrectionDetail::new).toList(); + this.memberId = diary.getMemberId(); + this.diaryId = diary.getId(); + } + + public List toDomain() { + return this.corrections.stream().map(CorrectionDetail::toDomain).toList(); + } +} diff --git a/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/persistence/repository/CorrectionRepository.java b/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/persistence/repository/CorrectionRepository.java new file mode 100644 index 00000000..488e9bf7 --- /dev/null +++ b/smeem-output-persistence/mongodb/src/main/java/com/smeem/output/persistence/mongodb/persistence/repository/CorrectionRepository.java @@ -0,0 +1,19 @@ +package com.smeem.output.persistence.mongodb.persistence.repository; + +import com.smeem.output.persistence.mongodb.persistence.document.CorrectionDocument; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface CorrectionRepository extends MongoRepository { + int countByMemberIdAndCreatedAtBetween(long memberId, LocalDateTime startAt, LocalDateTime endAt); + + Optional findByDiaryId(long diaryId); + + void deleteByDiaryId(long diaryId); + + void deleteByMemberId(long memberId); +} diff --git a/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-dev.yml b/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-dev.yml new file mode 100644 index 00000000..68043638 --- /dev/null +++ b/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-dev.yml @@ -0,0 +1,8 @@ +spring: + config: + activate: + on-profile: dev + + data: + mongodb: + uri: mongodb+srv://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_HOST}/smeem?tls=true diff --git a/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-local.yml b/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-local.yml new file mode 100644 index 00000000..40a2aa56 --- /dev/null +++ b/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-local.yml @@ -0,0 +1,8 @@ +spring: + config: + activate: + on-profile: local + + data: + mongodb: + uri: mongodb+srv://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_HOST}/smeem?tls=true diff --git a/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-prod.yml b/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-prod.yml new file mode 100644 index 00000000..cabb07e5 --- /dev/null +++ b/smeem-output-persistence/mongodb/src/main/resources/mongo-config/application-prod.yml @@ -0,0 +1,8 @@ +spring: + config: + activate: + on-profile: prod + + data: + mongodb: + uri: mongodb+srv://${MONGO_USERNAME}:${MONGO_PASSWORD}@${MONGO_HOST}/smeem?tls=true diff --git a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/DiaryAdapter.java b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/DiaryAdapter.java index 93d37560..1d929f02 100644 --- a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/DiaryAdapter.java +++ b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/DiaryAdapter.java @@ -6,12 +6,8 @@ import com.smeem.common.exception.SmeemException; import com.smeem.persistence.postgresql.persistence.entity.DeletedDiaryEntity; import com.smeem.persistence.postgresql.persistence.entity.DiaryEntity; -import com.smeem.persistence.postgresql.persistence.entity.MemberEntity; -import com.smeem.persistence.postgresql.persistence.entity.TopicEntity; import com.smeem.persistence.postgresql.persistence.repository.DeletedDiaryRepository; import com.smeem.persistence.postgresql.persistence.repository.DiaryRepository; -import com.smeem.persistence.postgresql.persistence.repository.MemberRepository; -import com.smeem.persistence.postgresql.persistence.repository.TopicRepository; import lombok.RequiredArgsConstructor; import lombok.val; import org.springframework.stereotype.Repository; @@ -23,8 +19,6 @@ @RequiredArgsConstructor public class DiaryAdapter implements DiaryPort { private final DiaryRepository diaryRepository; - private final MemberRepository memberRepository; - private final TopicRepository topicRepository; private final DeletedDiaryRepository deletedDiaryRepository; @Override @@ -34,7 +28,7 @@ public int countByMember(long memberId) { @Override public Diary save(Diary diary) { - return diaryRepository.save(DiaryEntity.of(diary)).toDomain(); + return diaryRepository.save(new DiaryEntity(diary)).toDomain(); } @Override @@ -42,14 +36,6 @@ public boolean isExistByMemberAndYesterday(long memberId) { return diaryRepository.existsByMemberIdAndYesterday(memberId); } - @Override - public Diary findByIdJoinMemberAndTopic(long id) { - val foundDiary = find(id); - val member = findByMemberId(foundDiary.getMemberId()).toDomain(); - val topic = foundDiary.getTopicId() != null ? findByTopicId(foundDiary.getTopicId()).toDomain() : null; - return foundDiary.toDomain(member, topic); - } - @Override public Diary findById(long id) { return find(id).toDomain(); @@ -95,16 +81,4 @@ private DiaryEntity find(long id) { return diaryRepository.findById(id) .orElseThrow(() -> new SmeemException(ExceptionCode.NOT_FOUND, "(Diary ID: " + id + ")")); } - - private MemberEntity findByMemberId(long id) { - return memberRepository.findById(id) - .orElseThrow(() -> new SmeemException(ExceptionCode.NOT_FOUND, "(Member ID: " + id + ")")); - } - - private TopicEntity findByTopicId(long id) { - return topicRepository.findById(id) - .orElseThrow(() -> new SmeemException( - ExceptionCode.NOT_FOUND, - "(서버 개발자에게 문의: Topic ID: " + id + ")")); - } } diff --git a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/TopicAdapter.java b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/TopicAdapter.java index 7f11bd75..beda90d1 100644 --- a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/TopicAdapter.java +++ b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/TopicAdapter.java @@ -24,6 +24,13 @@ public Topic findRandom() { return topicRepository.findRandom().toDomain(); } + @Override + public void checkValidation(long topicId) { + if (!topicRepository.existsById(topicId)) { + throw new SmeemException(ExceptionCode.NOT_FOUND, ": Topic ID=" + topicId); + } + } + private TopicEntity find(long id) { return topicRepository.findById(id) .orElseThrow(() -> new SmeemException( diff --git a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/VisitAdapter.java b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/VisitAdapter.java new file mode 100644 index 00000000..fd5a7f22 --- /dev/null +++ b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/adapter/VisitAdapter.java @@ -0,0 +1,22 @@ +package com.smeem.persistence.postgresql.adapter; + +import com.smeem.application.port.output.persistence.VisitPort; +import com.smeem.persistence.postgresql.persistence.entity.VisitEntity; +import com.smeem.persistence.postgresql.persistence.repository.member.VisitRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; + +@Component +@RequiredArgsConstructor +public class VisitAdapter implements VisitPort { + private final VisitRepository visitRepository; + + @Override + public void update(LocalDate date, String bitmap) { + VisitEntity visit = visitRepository.findByDate(date) + .orElseGet(() -> visitRepository.save(new VisitEntity(date, bitmap))); + visit.update(bitmap); + } +} diff --git a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/entity/DiaryEntity.java b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/entity/DiaryEntity.java index e90e42c2..fa5d606a 100644 --- a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/entity/DiaryEntity.java +++ b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/entity/DiaryEntity.java @@ -2,16 +2,12 @@ import com.smeem.application.domain.diary.Diary; import com.smeem.application.domain.generic.LangType; -import com.smeem.application.domain.member.Member; -import com.smeem.application.domain.topic.Topic; import jakarta.persistence.*; import lombok.*; @Entity @Table(name = "diary", schema = "smeem") -@Builder(access = AccessLevel.PRIVATE) -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class DiaryEntity extends BaseEntity { @Id @@ -26,13 +22,11 @@ public class DiaryEntity extends BaseEntity { @Column(nullable = false) private long memberId; - public static DiaryEntity of(Diary diary) { - return DiaryEntity.builder() - .content(diary.getContent()) - .targetLang(diary.getTargetLang()) - .topicId(diary.getTopic() != null ? diary.getTopic().getId() : null) - .memberId(diary.getMember().getId()) - .build(); + public DiaryEntity(Diary diary) { + this.content = diary.getContent(); + this.targetLang = diary.getTargetLang(); + this.topicId = diary.getTopicId(); + this.memberId = diary.getMemberId(); } public Diary toDomain() { @@ -40,24 +34,16 @@ public Diary toDomain() { .id(id) .content(content) .targetLang(targetLang) + .topicId(topicId) + .memberId(memberId) .createdAt(createdAt) - .build(); - } - - public Diary toDomain(Member member, Topic topic) { - return Diary.builder() - .id(id) - .content(content) - .targetLang(targetLang) - .createdAt(createdAt) - .member(member) - .topic(topic) + .updatedAt(updatedAt) .build(); } public DiaryEntity update(Diary diary) { this.content = diary.getContent(); - this.topicId = diary.getTopic() != null ? diary.getTopic().getId() : null; + this.topicId = diary.getTopicId(); return this; } } diff --git a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/entity/VisitEntity.java b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/entity/VisitEntity.java new file mode 100644 index 00000000..68bfc09b --- /dev/null +++ b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/entity/VisitEntity.java @@ -0,0 +1,31 @@ +package com.smeem.persistence.postgresql.persistence.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name = "visit", schema = "smeem") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class VisitEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private LocalDate date; + + @Column(columnDefinition = "TEXT", nullable = false) + private String log; + + public VisitEntity(LocalDate date, String log) { + this.date = date; + this.log = log; + } + + public void update(String log) { + this.log = log; + } +} diff --git a/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/repository/member/VisitRepository.java b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/repository/member/VisitRepository.java new file mode 100644 index 00000000..0527497f --- /dev/null +++ b/smeem-output-persistence/postgresql/src/main/java/com/smeem/persistence/postgresql/persistence/repository/member/VisitRepository.java @@ -0,0 +1,11 @@ +package com.smeem.persistence.postgresql.persistence.repository.member; + +import com.smeem.persistence.postgresql.persistence.entity.VisitEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDate; +import java.util.Optional; + +public interface VisitRepository extends JpaRepository { + Optional findByDate(LocalDate date); +} diff --git a/smeem-output-persistence/postgresql/src/main/resources/postgres-config/application-dev.yml b/smeem-output-persistence/postgresql/src/main/resources/postgres-config/application-dev.yml index da69532b..6a014511 100644 --- a/smeem-output-persistence/postgresql/src/main/resources/postgres-config/application-dev.yml +++ b/smeem-output-persistence/postgresql/src/main/resources/postgres-config/application-dev.yml @@ -13,7 +13,7 @@ spring: ddl-auto: validate properties: hibernate: - format_sql: true +# format_sql: true default_batch_fetch_size: 1000 auto_quote_keyword: true # generate_statistics: true diff --git a/smeem-output-persistence/postgresql/src/main/resources/postgres-config/application-prod.yml b/smeem-output-persistence/postgresql/src/main/resources/postgres-config/application-prod.yml index 27ec4472..768c30fd 100644 --- a/smeem-output-persistence/postgresql/src/main/resources/postgres-config/application-prod.yml +++ b/smeem-output-persistence/postgresql/src/main/resources/postgres-config/application-prod.yml @@ -13,7 +13,7 @@ spring: ddl-auto: validate properties: hibernate: - format_sql: true +# format_sql: true default_batch_fetch_size: 1000 auto_quote_keyword: true # generate_statistics: true diff --git a/smeem-output-web/build.gradle b/smeem-output-web/build.gradle new file mode 100644 index 00000000..694c81b9 --- /dev/null +++ b/smeem-output-web/build.gradle @@ -0,0 +1,32 @@ +project(':smeem-output-web') { + dependencies { + } +} + +project(':smeem-output-web:gpt') { + dependencies { + // Spring AI + implementation platform("org.springframework.ai:spring-ai-bom:1.0.0-SNAPSHOT") + implementation 'org.springframework.ai:spring-ai-openai' + + // jackson + implementation 'com.fasterxml.jackson.core:jackson-databind:2.18.0' + } +} + +allprojects { + dependencies { + implementation project(':smeem-common') + implementation project(':smeem-application') + + implementation 'org.springframework.boot:spring-boot-starter-web' + } + + tasks.bootJar { + enabled = false + } + + tasks.jar { + enabled = true + } +} diff --git a/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/OpenAiAdapter.java b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/OpenAiAdapter.java new file mode 100644 index 00000000..712f4228 --- /dev/null +++ b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/OpenAiAdapter.java @@ -0,0 +1,60 @@ +package com.smeem.output.web.gpt.adapter; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.smeem.application.domain.diary.Correction; +import com.smeem.application.port.output.web.openai.OpenAiPort; +import com.smeem.common.exception.ExceptionCode; +import com.smeem.common.exception.SmeemException; +import com.smeem.output.web.gpt.adapter.dto.CorrectionsResponse; +import com.smeem.output.web.gpt.adapter.template.PromptTemplate; +import com.smeem.output.web.gpt.adapter.template.SchemeTemplate; +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.ResponseFormat; +import org.springframework.stereotype.Component; + +import java.util.List; + +import static org.springframework.ai.openai.api.ResponseFormat.Type.JSON_SCHEMA; + +@Component +@RequiredArgsConstructor +public class OpenAiAdapter implements OpenAiPort { + private final ChatClient chatClient; + private final OpenAiChatModel chatModel; + private final ObjectMapper objectMapper; + + @Override + public String prompt(String message) { + try { + return chatClient.prompt() + .user(message) + .call() + .content(); + } catch (Exception exception) { + throw new SmeemException(ExceptionCode.OPEN_AI_SERVICE_AVAILABLE, exception.getMessage()); + } + } + + @Override + public List promptCorrections(String content) { + OpenAiChatOptions options = OpenAiChatOptions.builder() + .withResponseFormat(new ResponseFormat(JSON_SCHEMA, SchemeTemplate.getCorrectionScheme())) + .build(); + + Prompt prompt = new Prompt(PromptTemplate.getCorrectionPrompt(content), options); + ChatResponse call = chatModel.call(prompt); + String response = call.getResult().getOutput().getContent(); + + try { + return objectMapper.readValue(response, CorrectionsResponse.class).results(); + } catch (JsonProcessingException e) { + throw new SmeemException(ExceptionCode.OPEN_AI_SERVICE_AVAILABLE, e.getMessage()); + } + } +} diff --git a/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/dto/CorrectionsResponse.java b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/dto/CorrectionsResponse.java new file mode 100644 index 00000000..e010c234 --- /dev/null +++ b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/dto/CorrectionsResponse.java @@ -0,0 +1,10 @@ +package com.smeem.output.web.gpt.adapter.dto; + +import com.smeem.application.domain.diary.Correction; + +import java.util.List; + +public record CorrectionsResponse( + List results +) { +} diff --git a/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/template/PromptTemplate.java b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/template/PromptTemplate.java new file mode 100644 index 00000000..aaf2246a --- /dev/null +++ b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/template/PromptTemplate.java @@ -0,0 +1,19 @@ +package com.smeem.output.web.gpt.adapter.template; + +public class PromptTemplate { + public static String getCorrectionPrompt(String content) { + return String.format(""" + Paragraph : %s + + Please correct the following English sentences, determine whether each sentence is correct or incorrect, and generate a JSON response in the specified format. + Make sure to keep the order of the sentences. + The JSON format should be as follows: + + JSON response description: + - original_sentence: The original sentence exactly as it appears. + - corrected_sentence: The corrected sentence (if correct, repeat the original). + - reason: The reason for the correction, explained in Korean. + - is_correct: Whether the sentence has been corrected. Set to true if corrected, and false if unchanged. + """, content); + } +} diff --git a/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/template/SchemeTemplate.java b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/template/SchemeTemplate.java new file mode 100644 index 00000000..a83c6b4b --- /dev/null +++ b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/adapter/template/SchemeTemplate.java @@ -0,0 +1,29 @@ +package com.smeem.output.web.gpt.adapter.template; + +public class SchemeTemplate { + public static String getCorrectionScheme() { + return """ + { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "original_sentence": {"type": "string"}, + "corrected_sentence": {"type": "string"}, + "reason": {"type": "string"}, + "is_corrected": {"type": "boolean"} + }, + "required": ["original_sentence", "corrected_sentence", "reason", "is_corrected"], + "additionalProperties": false + } + } + }, + "required": ["results"], + "additionalProperties": false + } + """; + } +} diff --git a/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/config/OpenAiConfig.java b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/config/OpenAiConfig.java new file mode 100644 index 00000000..e7e75208 --- /dev/null +++ b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/config/OpenAiConfig.java @@ -0,0 +1,39 @@ +package com.smeem.output.web.gpt.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties(OpenAiProperties.class) +public class OpenAiConfig { + + @Bean + OpenAiApi openAiApi(OpenAiProperties properties) { + return new OpenAiApi(properties.openai().api()); + } + + @Bean + public OpenAiChatModel openAiChatModel(OpenAiApi openAiApi) { + OpenAiChatOptions options = OpenAiChatOptions.builder() + .withModel("gpt-4o-mini") + .withTemperature(0.4) + .build(); + return new OpenAiChatModel(openAiApi, options); + } + + @Bean + public ChatClient.Builder chatClientBuilder(OpenAiChatModel openAiChatModel) { + return ChatClient.builder(openAiChatModel) + .defaultSystem("안녕하세요."); + } + + @Bean + public ChatClient chatClient(ChatClient.Builder builder) { + return builder.build(); + } +} diff --git a/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/config/OpenAiProperties.java b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/config/OpenAiProperties.java new file mode 100644 index 00000000..0175aee2 --- /dev/null +++ b/smeem-output-web/gpt/src/main/java/com/smeem/output/web/gpt/config/OpenAiProperties.java @@ -0,0 +1,14 @@ +package com.smeem.output.web.gpt.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "gpt") +public record OpenAiProperties( + OpenAI openai +) { + + public record OpenAI( + String api + ) { + } +} diff --git a/smeem-output-web/gpt/src/main/resources/gpt-config/application.yml b/smeem-output-web/gpt/src/main/resources/gpt-config/application.yml new file mode 100644 index 00000000..b8a5ffb5 --- /dev/null +++ b/smeem-output-web/gpt/src/main/resources/gpt-config/application.yml @@ -0,0 +1,3 @@ +gpt: + openai: + api: ${CHAT_GPT_TOKEN}