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
-
+
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}