diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml new file mode 100644 index 0000000..15be1e9 --- /dev/null +++ b/.github/workflows/CI.yml @@ -0,0 +1,48 @@ +name: CI with Gradle + +on: + push: + branches-ignore: + - main + - develop + +jobs: + CI: + name: Continuous Integration + runs-on: ubuntu-latest + permissions: + contents: read + + services: + mongo: + image: mongo:6.0 + ports: + - 27017:27017 + env: + MONGO_INITDB_ROOT_USERNAME: test + MONGO_INITDB_ROOT_PASSWORD: testPW + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Wait for MongoDB to be ready + run: | + echo "Waiting for MongoDB to start..." + sleep 10 + + - name: Build and Test with Gradle Wrapper + env: + SPRING_DATA_MONGODB_URI: "mongodb://test:testPW@localhost:27017/testdb" + FASTAPI_BASE_URL: ${{ secrets.FASTAPI_BASE_URL }} + run: | + ./gradlew build test \ No newline at end of file diff --git a/.github/workflows/CICD.yml b/.github/workflows/CICD.yml new file mode 100644 index 0000000..68487c0 --- /dev/null +++ b/.github/workflows/CICD.yml @@ -0,0 +1,142 @@ +name: CI/CD FOR DEVELOP + +on: + push: + branches: + - main + - develop + +env: + DOCKERHUB_REPOSITORY: ${{ secrets.DOCKER_REPOSITORY }} + FASTAPI_BASE_URL: ${{ secrets.FASTAPI_BASE_URL }} + +jobs: + CI: + name: Continuous Integration + runs-on: ubuntu-latest + permissions: + contents: read + + services: + mongo: + image: mongo:6.0 + ports: + - 27017:27017 + env: + MONGO_INITDB_ROOT_USERNAME: test + MONGO_INITDB_ROOT_PASSWORD: testPW + + steps: + - name: Get short SHA + id: slug + run: echo "sha7=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT + + + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + + - name: Wait for MongoDB to start + run: sleep 10 + + - name: Build and Test with Gradle Wrapper + env: + SPRING_DATA_MONGODB_URI: "mongodb://test:testPW@localhost:27017/testdb" + FASTAPI_BASE_URL: ${{ secrets.FASTAPI_BASE_URL }} + run: | + ./gradlew build test + + - name: Upload jar file to Artifact + uses: actions/upload-artifact@v4 + with: + name: jar_files + path: build/libs/*.jar + + - name: Upload Dockerfile to Artifact + uses: actions/upload-artifact@v4 + with: + name: Dockerfile + path: ./Dockerfile + + CD_Delivery_to_DockerHub: + name: CD_Delivery_to_DockerHub + needs: CI + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Download jar file from Artifact + uses: actions/download-artifact@v4 + with: + name: jar_files + path: build/libs + + - name: Download Dockerfile file from Artifact + uses: actions/download-artifact@v4 + with: + name: Dockerfile + path: ./ + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get short SHA + id: slug + run: echo "sha7=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Build, tag, and push image to DockerHub + id: build-image + env: + USERNAME: ${{ secrets.DOCKER_USERNAME }} + IMAGE_TAG: ${{ steps.slug.outputs.sha7 }} + FASTAPI_BASE_URL: ${{ secrets.FASTAPI_BASE_URL }} + + run: | + docker build -t $USERNAME/$DOCKERHUB_REPOSITORY:$IMAGE_TAG -t $USERNAME/$DOCKERHUB_REPOSITORY:latest . + docker push $USERNAME/$DOCKERHUB_REPOSITORY --all-tags + echo "image=$USERNAME/$DOCKERHUB_REPOSITORY:$IMAGE_TAG&latest" >> $GITHUB_OUTPUT + + + CD_Deploy: + name: CD_Deploy + needs: CD_Delivery_to_DockerHub + runs-on: ubuntu-latest + + steps: + - name: Get short SHA + id: slug + run: echo "sha7=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT + + - name: Executing remote ssh commands + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.REMOTE_IP }} + username: ${{ secrets.REMOTE_USER }} + key: ${{ secrets.REMOTE_PRIVATE_KEY }} + port: ${{ secrets.REMOTE_SSH_PORT }} + script: | + export DOCKER_IMAGE="${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:latest" + export DOCKER_COMPOSE_PATH="${{ secrets.DOCKER_COMPOSE_PATH }}" + export FASTAPI_BASE_URL="${{ secrets.FASTAPI_BASE_URL }}" + + cd /home/ubuntu/scripts + ./rolling-update.sh + + echo "Stopping current containers..." + docker compose -f $DOCKER_COMPOSE_PATH down + + echo "Pulling the latest image..." + docker compose -f $DOCKER_COMPOSE_PATH pull + + echo "Starting new deployment..." + docker compose -f $DOCKER_COMPOSE_PATH up -d \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f87de02 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17-alpine +ARG JAR_FILE=build/libs/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "/app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index 814f347..9765d64 100644 --- a/build.gradle +++ b/build.gradle @@ -30,6 +30,10 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + //swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + //webflux + implementation 'org.springframework.boot:spring-boot-starter-webflux' } tasks.named('test') { diff --git a/init.js b/init.js new file mode 100644 index 0000000..205332a --- /dev/null +++ b/init.js @@ -0,0 +1,6 @@ +db = db.getSiblingDB('testdb'); +db.createUser({ + user: "test", + pwd: "testPW", + roles: [{ role: "readWrite", db: "testdb" }] +}); diff --git a/src/main/java/com/going/server/domain/word/controller/WordController.java b/src/main/java/com/going/server/domain/word/controller/WordController.java new file mode 100644 index 0000000..15cbda2 --- /dev/null +++ b/src/main/java/com/going/server/domain/word/controller/WordController.java @@ -0,0 +1,34 @@ +package com.going.server.domain.word.controller; + +import com.going.server.domain.word.dto.WordResponseDto; +import com.going.server.domain.word.service.WordService; +import com.going.server.global.response.SuccessResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; + +import java.util.List; + +@Controller +@RequestMapping("/words") +@RequiredArgsConstructor +public class WordController { + private final WordService wordService; + + //대표어휘 조회 + @GetMapping() + public SuccessResponse> getWords() { + List response = wordService.getWords(); + return SuccessResponse.of(response); + } + + //구성어휘 조회 + @GetMapping("/composition-word") + public SuccessResponse> getCompositionWords() { + List response = wordService.getCompositionWords(); + return SuccessResponse.of(response); + } + +} diff --git a/src/main/java/com/going/server/domain/word/dto/CompositionClusterResponseDto.java b/src/main/java/com/going/server/domain/word/dto/CompositionClusterResponseDto.java new file mode 100644 index 0000000..0f32af7 --- /dev/null +++ b/src/main/java/com/going/server/domain/word/dto/CompositionClusterResponseDto.java @@ -0,0 +1,15 @@ +package com.going.server.domain.word.dto; + +import lombok.*; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CompositionClusterResponseDto { + private Integer clusterId; // 클러스터 ID + private List words; // 해당 클러스터의 단어 목록 +} diff --git a/src/main/java/com/going/server/domain/word/dto/CompositionWordResponseDto.java b/src/main/java/com/going/server/domain/word/dto/CompositionWordResponseDto.java new file mode 100644 index 0000000..5c11a21 --- /dev/null +++ b/src/main/java/com/going/server/domain/word/dto/CompositionWordResponseDto.java @@ -0,0 +1,21 @@ +package com.going.server.domain.word.dto; + +import com.going.server.domain.word.entity.CompositionWord; +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CompositionWordResponseDto { + private String compositionWord; + private Boolean isRepresent; + + public static CompositionWordResponseDto of(CompositionWord compositionWord) { + return new CompositionWordResponseDto( + compositionWord.getCompositionWord(), + compositionWord.getIsRepresent() + ); + } +} diff --git a/src/main/java/com/going/server/domain/word/dto/WordResponseDto.java b/src/main/java/com/going/server/domain/word/dto/WordResponseDto.java new file mode 100644 index 0000000..8da3a7e --- /dev/null +++ b/src/main/java/com/going/server/domain/word/dto/WordResponseDto.java @@ -0,0 +1,14 @@ +package com.going.server.domain.word.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class WordResponseDto { + private Integer clusterId; // 클러스터 ID 추가 + private String word; + private String result_img; +} diff --git a/src/main/java/com/going/server/domain/word/entity/CompositionWord.java b/src/main/java/com/going/server/domain/word/entity/CompositionWord.java new file mode 100644 index 0000000..4ec0fe2 --- /dev/null +++ b/src/main/java/com/going/server/domain/word/entity/CompositionWord.java @@ -0,0 +1,31 @@ +package com.going.server.domain.word.entity; + +import lombok.*; +import org.bson.types.ObjectId; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; + +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Document(collection = "composition_words") +public class CompositionWord { + @Id + private String compositionId; + + @Field(name = "composition_word") + private String compositionWord; + + @Field(name = "is_represent") + private Boolean isRepresent; + + // ID를 자동으로 생성하는 생성자 추가 + public CompositionWord(String word) { + this.compositionId = new ObjectId().toString(); // MongoDB의 ObjectId를 자동으로 생성 + this.compositionWord = word; + this.isRepresent = false; + } +} diff --git a/src/main/java/com/going/server/domain/word/entity/Word.java b/src/main/java/com/going/server/domain/word/entity/Word.java new file mode 100644 index 0000000..716c1fc --- /dev/null +++ b/src/main/java/com/going/server/domain/word/entity/Word.java @@ -0,0 +1,30 @@ +package com.going.server.domain.word.entity; + +import com.going.server.global.common.BaseEntity; +import lombok.*; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; +import org.springframework.data.mongodb.core.mapping.Field; +import org.bson.types.ObjectId; + +@Document(collection = "words") // MongoDB 컬렉션 지정 +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Word extends BaseEntity { + + @Id + private String id; // MongoDB에서 자동 생성될 ObjectId를 String으로 저장 + + @Field(name = "word") + private String word; + + // ID를 자동으로 생성하는 생성자 추가 + public Word(String word) { + this.id = new ObjectId().toString(); // MongoDB의 ObjectId를 자동으로 생성 + this.word = word; + } + +} diff --git a/src/main/java/com/going/server/domain/word/repository/WordRepository.java b/src/main/java/com/going/server/domain/word/repository/WordRepository.java new file mode 100644 index 0000000..00efd15 --- /dev/null +++ b/src/main/java/com/going/server/domain/word/repository/WordRepository.java @@ -0,0 +1,7 @@ +package com.going.server.domain.word.repository; + +import com.going.server.domain.word.entity.Word; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface WordRepository extends MongoRepository { +} diff --git a/src/main/java/com/going/server/domain/word/service/WordService.java b/src/main/java/com/going/server/domain/word/service/WordService.java new file mode 100644 index 0000000..2bd431a --- /dev/null +++ b/src/main/java/com/going/server/domain/word/service/WordService.java @@ -0,0 +1,11 @@ +package com.going.server.domain.word.service; + +import com.going.server.domain.word.dto.WordResponseDto; + +import java.util.List; + +public interface WordService { + List getWords(); + + List getCompositionWords(); +} diff --git a/src/main/java/com/going/server/domain/word/service/WordServiceImpl.java b/src/main/java/com/going/server/domain/word/service/WordServiceImpl.java new file mode 100644 index 0000000..27bac2c --- /dev/null +++ b/src/main/java/com/going/server/domain/word/service/WordServiceImpl.java @@ -0,0 +1,19 @@ +package com.going.server.domain.word.service; + +import com.going.server.domain.word.dto.WordResponseDto; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class WordServiceImpl implements WordService { + @Override + public List getWords() { + return List.of(); + } + + @Override + public List getCompositionWords() { + return List.of(); + } +} diff --git a/src/main/java/com/going/server/global/common/BaseEntity.java b/src/main/java/com/going/server/global/common/BaseEntity.java new file mode 100644 index 0000000..1d1600b --- /dev/null +++ b/src/main/java/com/going/server/global/common/BaseEntity.java @@ -0,0 +1,21 @@ +package com.going.server.global.common; + +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.mongodb.core.mapping.Field; +import org.springframework.data.mongodb.core.mapping.FieldType; + +import java.time.LocalDateTime; + +@Getter +public abstract class BaseEntity { + @CreatedDate + @Field(name = "created_at", targetType = FieldType.TIMESTAMP) + private LocalDateTime createAt; + + @LastModifiedDate + @Field(name = "updated_at", targetType = FieldType.TIMESTAMP) + private LocalDateTime updateAt; +} + diff --git a/src/main/java/com/going/server/global/config/SwaggerConfig.java b/src/main/java/com/going/server/global/config/SwaggerConfig.java new file mode 100644 index 0000000..c0d278e --- /dev/null +++ b/src/main/java/com/going/server/global/config/SwaggerConfig.java @@ -0,0 +1,21 @@ +package com.going.server.global.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI OpenAPI() { + Info info = new Info() + .title("Test SpringBoot API") + .description("

CapGoing API 명세서

") + .version("1.0.0"); + + return new OpenAPI() + .info(info); + } +} diff --git a/src/main/java/com/going/server/global/config/WebClientConfig.java b/src/main/java/com/going/server/global/config/WebClientConfig.java new file mode 100644 index 0000000..6d1587d --- /dev/null +++ b/src/main/java/com/going/server/global/config/WebClientConfig.java @@ -0,0 +1,15 @@ +package com.going.server.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + @Bean + @Primary + public WebClient.Builder webClientBuilder() { + return WebClient.builder(); + } +} diff --git a/src/main/java/com/going/server/global/constant/StaticValue.java b/src/main/java/com/going/server/global/constant/StaticValue.java new file mode 100644 index 0000000..033c2bf --- /dev/null +++ b/src/main/java/com/going/server/global/constant/StaticValue.java @@ -0,0 +1,12 @@ +package com.going.server.global.constant; + +public class StaticValue { + + public static final int BAD_REQUEST = 400; + public static final int UNAUTHORIZED = 401; + public static final int FORBIDDEN = 403; + public static final int NOT_FOUND = 404; + public static final int METHOD_NOT_ALLOWED = 405; + public static final int DUPLICATED = 409; + public static final int INTERNAL_SERVER_ERROR = 500; +} diff --git a/src/main/java/com/going/server/global/exception/BaseErrorCode.java b/src/main/java/com/going/server/global/exception/BaseErrorCode.java new file mode 100644 index 0000000..a13a5f5 --- /dev/null +++ b/src/main/java/com/going/server/global/exception/BaseErrorCode.java @@ -0,0 +1,8 @@ +package com.going.server.global.exception; + +public interface BaseErrorCode { + + String getCode(); + String getMessage(); + int getHttpStatus(); +} diff --git a/src/main/java/com/going/server/global/exception/BaseException.java b/src/main/java/com/going/server/global/exception/BaseException.java new file mode 100644 index 0000000..41a6ed6 --- /dev/null +++ b/src/main/java/com/going/server/global/exception/BaseException.java @@ -0,0 +1,10 @@ +package com.going.server.global.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class BaseException extends RuntimeException{ + private final BaseErrorCode errorCode; +} diff --git a/src/main/java/com/going/server/global/exception/ErrorResponse.java b/src/main/java/com/going/server/global/exception/ErrorResponse.java new file mode 100644 index 0000000..7f9dd01 --- /dev/null +++ b/src/main/java/com/going/server/global/exception/ErrorResponse.java @@ -0,0 +1,43 @@ +package com.going.server.global.exception; + +import com.going.server.global.response.BaseResponse; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class ErrorResponse extends BaseResponse { + + private final int httpStatus; + + @Builder + public ErrorResponse(String code, String message, int httpStatus) { + super(false, code, message); + this.httpStatus = httpStatus; + } + + public static ErrorResponse of(BaseErrorCode errorCode) { + return ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .httpStatus(errorCode.getHttpStatus()) + .build(); + } + + public static ErrorResponse of(BaseErrorCode errorCode, String message) { + return ErrorResponse.builder() + .code(errorCode.getCode()) + .message(message) + .httpStatus(errorCode.getHttpStatus()) + .build(); + } + + public static ErrorResponse of(String code, String message, int httpStatus) { + return ErrorResponse.builder() + .code(code) + .message(message) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/going/server/global/exception/GlobalErrorCode.java b/src/main/java/com/going/server/global/exception/GlobalErrorCode.java new file mode 100644 index 0000000..d9ac26f --- /dev/null +++ b/src/main/java/com/going/server/global/exception/GlobalErrorCode.java @@ -0,0 +1,22 @@ +package com.going.server.global.exception; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +import static com.going.server.global.constant.StaticValue.*; + +@Getter +@AllArgsConstructor +public enum GlobalErrorCode implements BaseErrorCode { + + BAD_REQUEST_ERROR(BAD_REQUEST, "GLOBAL_400_1", "잘못된 요청입니다."), + INVALID_HTTP_MESSAGE_BODY(BAD_REQUEST, "GLOBAL_400_2", "HTTP 요청 바디의 형식이 잘못되었습니다."), + ACCESS_DENIED_REQUEST(FORBIDDEN, "GLOBAL_403", "해당 요청에 접근 권한이 없습니다."), + UNSUPPORTED_HTTP_METHOD(METHOD_NOT_ALLOWED, "GLOBAL_405", "지원하지 않는 HTTP 메서드입니다."), + SERVER_ERROR(INTERNAL_SERVER_ERROR, "GLOBAL_500", "서버 내부에서 알 수 없는 오류가 발생했습니다."); + + private final int httpStatus; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/going/server/global/exception/GlobalExceptionHandler.java b/src/main/java/com/going/server/global/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..cd93824 --- /dev/null +++ b/src/main/java/com/going/server/global/exception/GlobalExceptionHandler.java @@ -0,0 +1,79 @@ +package com.going.server.global.exception; + +import java.net.BindException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + /* + javax.validation.Valid or @Validated 으로 binding error 발생시 발생 + 주로 @RequestBody, @RequestPart 어노테이션에서 발생 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + private ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("MethodArgumentNotValidException Error", e); + ErrorResponse error = ErrorResponse.of(GlobalErrorCode.INVALID_HTTP_MESSAGE_BODY, + e.getFieldError().getDefaultMessage()); + return ResponseEntity.status(error.getHttpStatus()).body(error); + } + + /* binding error 발생시 BindException 발생 */ + @ExceptionHandler(BindException.class) + private ResponseEntity handleBindException(BindException e) { + ErrorResponse error = ErrorResponse.of(GlobalErrorCode.INVALID_HTTP_MESSAGE_BODY); + return ResponseEntity.status(error.getHttpStatus()).body(error); + } + + /* enum type 일치하지 않아 binding 못할 경우 발생 */ + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + private ResponseEntity handleMethodArgumentTypeMismatchException( + MethodArgumentTypeMismatchException e) { + log.error("MethodArgumentTypeMismatchException Error", e); + ErrorResponse error = ErrorResponse.of(GlobalErrorCode.INVALID_HTTP_MESSAGE_BODY); + return ResponseEntity.status(error.getHttpStatus()).body(error); + } + + /* 지원하지 않은 HTTP method 호출 할 경우 발생 */ + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + private ResponseEntity handleHttpRequestMethodNotSupportedException( + HttpRequestMethodNotSupportedException e) { + log.error("HttpRequestMethodNotSupportedException Error", e); + ErrorResponse error = ErrorResponse.of(GlobalErrorCode.UNSUPPORTED_HTTP_METHOD); + return ResponseEntity.status(error.getHttpStatus()).body(error); + } + + /* request 값을 읽을 수 없을 때 발생 */ + @ExceptionHandler(HttpMessageNotReadableException.class) + protected ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("HttpMessageNotReadableException error", e); + ErrorResponse error = ErrorResponse.of(GlobalErrorCode.BAD_REQUEST_ERROR); + return ResponseEntity.status(error.getHttpStatus()).body(error); + } + + /* 비지니스 로직 에러 */ + @ExceptionHandler(BaseException.class) + private ResponseEntity handleBusinessException(BaseException e) { + log.error("BusinessError "); + log.error(e.getErrorCode().getMessage()); + ErrorResponse error = ErrorResponse.of(e.getErrorCode()); + return ResponseEntity.status(error.getHttpStatus()).body(error); + } + + /* 나머지 예외 처리 */ + @ExceptionHandler(Exception.class) + private ResponseEntity handleException(Exception e) { + log.error("Exception Error ", e); + ErrorResponse error = ErrorResponse.of(GlobalErrorCode.SERVER_ERROR); + return ResponseEntity.status(error.getHttpStatus()).body(error); + } + +} diff --git a/src/main/java/com/going/server/global/response/BaseResponse.java b/src/main/java/com/going/server/global/response/BaseResponse.java new file mode 100644 index 0000000..1085884 --- /dev/null +++ b/src/main/java/com/going/server/global/response/BaseResponse.java @@ -0,0 +1,32 @@ +package com.going.server.global.response; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import com.going.server.global.exception.BaseErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@Getter +@ToString +@RequiredArgsConstructor +public class BaseResponse { + + private final String timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME); + private final Boolean isSuccess; + private final String code; + private final String message; + + public static BaseResponse of(Boolean isSuccess, BaseErrorCode errorCode) { + return new BaseResponse(isSuccess, errorCode.getCode(), errorCode.getMessage()); + } + + public static BaseResponse of(Boolean isSuccess, BaseErrorCode errorCode, String message) { + return new BaseResponse(isSuccess, errorCode.getCode(), message); + } + + public static BaseResponse of(Boolean isSuccess, String code, String message) { + return new BaseResponse(isSuccess, code, message); + } +} diff --git a/src/main/java/com/going/server/global/response/ErrorResponse.java b/src/main/java/com/going/server/global/response/ErrorResponse.java new file mode 100644 index 0000000..6bdfed1 --- /dev/null +++ b/src/main/java/com/going/server/global/response/ErrorResponse.java @@ -0,0 +1,43 @@ +package com.going.server.global.response; + +import com.going.server.global.exception.BaseErrorCode; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class ErrorResponse extends BaseResponse { + + private final int httpStatus; + + @Builder + public ErrorResponse(String code, String message, int httpStatus) { + super(false, code, message); + this.httpStatus = httpStatus; + } + + public static ErrorResponse of(BaseErrorCode errorCode) { + return ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .httpStatus(errorCode.getHttpStatus()) + .build(); + } + + public static ErrorResponse of(BaseErrorCode errorCode, String message) { + return ErrorResponse.builder() + .code(errorCode.getCode()) + .message(message) + .httpStatus(errorCode.getHttpStatus()) + .build(); + } + + public static ErrorResponse of(String code, String message, int httpStatus) { + return ErrorResponse.builder() + .code(code) + .message(message) + .httpStatus(httpStatus) + .build(); + } +} diff --git a/src/main/java/com/going/server/global/response/PageResponse.java b/src/main/java/com/going/server/global/response/PageResponse.java new file mode 100644 index 0000000..c911391 --- /dev/null +++ b/src/main/java/com/going/server/global/response/PageResponse.java @@ -0,0 +1,37 @@ +package com.going.server.global.response; + +/** + * 게시글 등의 리스트 조회 시 필요한 응답 클래스 + * Pageable 이용 + */ +import java.util.List; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Page; + +@Getter +@Builder +public class PageResponse { + + private int totalPage; + private Long totalElements; + private int pagingSize; + private int currentPage; + private Boolean isFirst; + private Boolean isLast; + private Boolean isEmpty; + private List data; + + public static PageResponse of(Page page) { + return PageResponse.builder() + .totalPage(page.getTotalPages()) + .totalElements(page.getTotalElements()) + .pagingSize(page.getSize()) + .currentPage(page.getNumber() + 1) + .isFirst(page.isFirst()) + .isLast(page.isLast()) + .isEmpty(page.isEmpty()) + .data(page.getContent()) + .build(); + } +} diff --git a/src/main/java/com/going/server/global/response/SuccessResponse.java b/src/main/java/com/going/server/global/response/SuccessResponse.java new file mode 100644 index 0000000..6139ac7 --- /dev/null +++ b/src/main/java/com/going/server/global/response/SuccessResponse.java @@ -0,0 +1,34 @@ +package com.going.server.global.response; + +import com.sun.net.httpserver.Authenticator.Success; +import lombok.Getter; +import lombok.ToString; + +@Getter +@ToString +public class SuccessResponse extends BaseResponse { + + private final T data; + + public SuccessResponse(T data) { + super(true, "200", "호출에 성공하였습니다."); + this.data = data; + } + + public SuccessResponse(T data, String code) { + super(true, code, "호출에 성공하였습니다."); + this.data = data; + } + + public static SuccessResponse of(T data) { + return new SuccessResponse<>(data); + } + + public static SuccessResponse of(T data, String code) { + return new SuccessResponse<>(data, code); + } + + public static SuccessResponse empty() { + return new SuccessResponse<>(null); + } +} diff --git a/src/main/java/com/going/server/global/temp/controller/FastApiController.java b/src/main/java/com/going/server/global/temp/controller/FastApiController.java new file mode 100644 index 0000000..086bcc1 --- /dev/null +++ b/src/main/java/com/going/server/global/temp/controller/FastApiController.java @@ -0,0 +1,74 @@ +package com.going.server.global.temp.controller; + +import com.going.server.domain.word.dto.CompositionClusterResponseDto; +import com.going.server.domain.word.dto.CompositionWordResponseDto; +import com.going.server.domain.word.dto.WordResponseDto; +import com.going.server.domain.word.entity.CompositionWord; +import com.going.server.global.response.SuccessResponse; +import com.going.server.global.temp.service.FastApiService; +import io.swagger.v3.oas.annotations.Operation; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/words") +@Tag(name = "FastAPI 통신 테스트", description = "FastAPI 서버와의 통신을 테스트하는 API") +@Slf4j +public class FastApiController { + + private final FastApiService fastApiService; + + @GetMapping("/test-fastapi") + @Operation(summary = "FastAPI 서버 테스트", description = "FastAPI 서버에 GET 요청을 보내고 정상적인 응답이 오는지 확인합니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "FastAPI 서버가 정상적으로 응답을 반환", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{\"message\":\"FastAPI is running!\"}") + ) + ) + }) + public ResponseEntity testFastApi() { + String response = fastApiService.callFastApi(); + return ResponseEntity.ok(response); + } + + @GetMapping() + @Operation(summary = "FastAPI 클러스터 결과 조회", description = "FastAPI 서버에서 클러스터링 결과를 가져옵니다.") + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "FastAPI에서 클러스터링 데이터를 성공적으로 반환", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = "{ \"message\": \"클러스터링 완료!\", \"clusters\": [{ \"cluster_id\": 0, \"word_sentences\": { \"정서진\": [ \"문장1\", \"문장2\" ] } }] }") + ) + ) + }) + public SuccessResponse> getCluster() { + return fastApiService.getCluster(); + } + + @GetMapping("/composition-word") + public SuccessResponse> getCompositionWords(@RequestParam Long clusterId) { + return fastApiService.getCompositionWords(clusterId); + } + + @GetMapping("/{clusterId}/{compositionWord}") + public SuccessResponse> getSentence(@PathVariable Long clusterId, @PathVariable String compositionWord) { + return fastApiService.getSentence(clusterId,compositionWord); + } +} diff --git a/src/main/java/com/going/server/global/temp/service/FastApiService.java b/src/main/java/com/going/server/global/temp/service/FastApiService.java new file mode 100644 index 0000000..be07558 --- /dev/null +++ b/src/main/java/com/going/server/global/temp/service/FastApiService.java @@ -0,0 +1,176 @@ +package com.going.server.global.temp.service; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.going.server.domain.word.dto.CompositionClusterResponseDto; +import com.going.server.domain.word.dto.CompositionWordResponseDto; +import com.going.server.domain.word.dto.WordResponseDto; +import com.going.server.domain.word.entity.CompositionWord; +import com.going.server.domain.word.entity.Word; +import com.going.server.domain.word.repository.WordRepository; +import com.going.server.global.response.SuccessResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Service +@Slf4j +public class FastApiService { + + private final WordRepository wordRepository; + @Value("${fastapi.base-url}") + private String baseUrl; + + private final WebClient webClient; + + public FastApiService(WebClient.Builder webClientBuilder, WordRepository wordRepository) { + this.webClient = webClientBuilder.build(); + this.wordRepository = wordRepository; + } + + /** + * FastAPI 서버의 기본 상태 확인 (GET 요청) + */ + public String callFastApi() { + return webClient.get() + .uri(baseUrl + "/") + .retrieve() + .bodyToMono(String.class) + .block(); + } + + /** + * FastAPI 서버에 클러스터링 요청 (POST 요청) + */ + public SuccessResponse> getCluster() { + Map response = webClient.post() + .uri(baseUrl + "/api/cluster") + .retrieve() + .bodyToMono(Map.class) + .block(); + + if (response == null || !response.containsKey("clusters")) { + return null; + } + + List> clusters = (List>) response.get("clusters"); + String imageUrl = response.get("image_url").toString(); + List wordEntities = new ArrayList<>(); + List responseDtos = new ArrayList<>(); + + for (Map cluster : clusters) { + Integer clusterId = (Integer) cluster.get("cluster_id"); // 클러스터 ID 가져오기 + Map> wordSentences = (Map>) cluster.get("word_sentences"); + + if (wordSentences != null && !wordSentences.isEmpty()) { + String firstKey = wordSentences.keySet().iterator().next(); // 첫 번째 키 가져오기 + Word wordEntity = new Word(firstKey); + wordEntities.add(wordEntity); + responseDtos.add(new WordResponseDto(clusterId, firstKey,imageUrl)); // 첫 번째 키와 클러스터 ID 저장 + } + } + + if (!wordEntities.isEmpty()) { + wordRepository.saveAll(wordEntities); // MongoDB에 첫 번째 키만 저장 + } + + return SuccessResponse.of(responseDtos); + } + + public SuccessResponse> getCompositionWords(Long clusterId) { + Map response = webClient.post() + .uri(baseUrl + "/api/cluster") + .retrieve() + .bodyToMono(Map.class) + .block(); + + if (response == null || !response.containsKey("clusters")) { + return null; + } + + List> clusters = (List>) response.get("clusters"); + List clusterResponseList = new ArrayList<>(); + + for (Map cluster : clusters) { + Integer currentClusterId = (Integer) cluster.get("cluster_id"); // 클러스터 ID 가져오기 + + // 파라미터로 받은 clusterId와 다르면 건너뛴다. + if (clusterId != null && !clusterId.equals(Long.valueOf(currentClusterId))) { + continue; + } + + Map> wordSentences = (Map>) cluster.get("word_sentences"); + List wordResponseList = new ArrayList<>(); + + if (wordSentences != null && !wordSentences.isEmpty()) { + for (String key : wordSentences.keySet()) { // 모든 키 저장 + CompositionWord compositionWord = new CompositionWord(key); + wordResponseList.add(CompositionWordResponseDto.of(compositionWord)); + } + } + + clusterResponseList.add(new CompositionClusterResponseDto(currentClusterId, wordResponseList)); + } + + return SuccessResponse.of(clusterResponseList); + } + + + public SuccessResponse> getSentence(Long clusterId, String compositionWord) { + Map response = webClient.post() + .uri(baseUrl + "/api/cluster") + .retrieve() + .bodyToMono(Map.class) + .block(); + + if (response == null || !response.containsKey("clusters")) { + System.out.println("❌ 클러스터 데이터 없음!"); + return SuccessResponse.of(Collections.emptyList()); + } + + List> clusters = (List>) response.get("clusters"); + + System.out.println("✅ FastAPI 응답 데이터: " + clusters); + + for (Map cluster : clusters) { + Integer currentClusterId = (Integer) cluster.get("cluster_id"); + + System.out.println("🔹 현재 클러스터 ID: " + currentClusterId); + + // 클러스터 ID가 요청된 clusterId와 일치하는지 확인 + if (!clusterId.equals(Long.valueOf(currentClusterId))) { + continue; + } + + Map> wordSentences = (Map>) cluster.get("word_sentences"); + + if (wordSentences == null) { + System.out.println("❌ wordSentences 데이터 없음!"); + return SuccessResponse.of(Collections.emptyList()); + } + + System.out.println("✅ 현재 클러스터의 wordSentences: " + wordSentences.keySet()); + + // 단어가 존재하는지 확인 + if (wordSentences.containsKey(compositionWord)) { + System.out.println("✅ 요청된 단어 (" + compositionWord + ") 의 문장 배열 반환: " + wordSentences.get(compositionWord)); + return SuccessResponse.of(wordSentences.get(compositionWord)); + } else { + System.out.println("❌ 요청된 단어 (" + compositionWord + ") 가 존재하지 않음!"); + } + } + + // 데이터가 없을 경우 빈 리스트 반환 + return SuccessResponse.of(Collections.emptyList()); + } +} diff --git a/src/main/resources/application-prod.properties b/src/main/resources/application-prod.properties index e69de29..46d8bb1 100644 --- a/src/main/resources/application-prod.properties +++ b/src/main/resources/application-prod.properties @@ -0,0 +1 @@ +fastapi.base-url=${FASTAPI_BASE_URL} \ No newline at end of file