diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e639dc9..7351b55 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -74,10 +74,11 @@ jobs: echo "JWT_SECRET=${{ secrets.JWT_SECRET }}" >> .env echo "ACCESS_EXPIRATION=${{ secrets.ACCESS_EXPIRATION }}" >> .env echo "REFRESH_EXPIRATION=${{ secrets.REFRESH_EXPIRATION }}" >> .env + echo "GCP_STT_KEY=${{ secrets.GCP_STT_KEY }}" >> .env cd ~/app sudo docker rm -f app-server || true sudo docker compose down || true sudo docker image rm ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPO }} || true sudo docker compose pull - sudo docker compose up -d \ No newline at end of file + sudo docker compose up -d diff --git a/Dockerfile b/Dockerfile index c0be1f9..30e336b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,4 +3,4 @@ FROM amazoncorretto:17 ARG JAR_FILE=build/libs/*.jar COPY ${JAR_FILE} app.jar -ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "-jar", "/app.jar"] \ No newline at end of file +ENTRYPOINT ["java", "-jar", "/app.jar"] diff --git a/build.gradle b/build.gradle index 47ee60c..c081c14 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // stt + implementation 'com.google.cloud:google-cloud-speech:4.72.0' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index eaa2f46..4db62e3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,11 +7,8 @@ services: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=prod - - JWT_SECRET=${JWT_SECRET} - - ACCESS_EXPIRATION=${ACCESS_EXPIRATION} - - REFRESH_EXPIRATION=${REFRESH_EXPIRATION} redis: image: redis:7.2 container_name: redis-server ports: - - "6379:6379" \ No newline at end of file + - "6379:6379" diff --git a/src/main/java/com/api/sss/config/GoogleCloudConfig.java b/src/main/java/com/api/sss/config/GoogleCloudConfig.java new file mode 100644 index 0000000..66df523 --- /dev/null +++ b/src/main/java/com/api/sss/config/GoogleCloudConfig.java @@ -0,0 +1,40 @@ +package com.api.sss.config; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Base64; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.speech.v1.SpeechClient; +import com.google.cloud.speech.v1.SpeechSettings; + +@Configuration +public class GoogleCloudConfig { + + private final GoogleCredentials googleCredentials; + + public GoogleCloudConfig( + @Value("${spring.cloud.gcp.credentials.encoded-key}") + String encodedKey + ) throws IOException { + byte[] decoded = Base64.getDecoder().decode(encodedKey); + this.googleCredentials = + GoogleCredentials.fromStream(new ByteArrayInputStream(decoded)); + } + + @Bean + public SpeechSettings speechSettings() throws IOException { + return SpeechSettings.newBuilder() + .setCredentialsProvider(() -> googleCredentials) + .build(); + } + + @Bean + public SpeechClient speechClient(SpeechSettings speechSettings) throws IOException { + return SpeechClient.create(speechSettings); + } +} diff --git a/src/main/java/com/api/sss/config/exception/ErrorCode.java b/src/main/java/com/api/sss/config/exception/ErrorCode.java index 9c0547a..7246317 100644 --- a/src/main/java/com/api/sss/config/exception/ErrorCode.java +++ b/src/main/java/com/api/sss/config/exception/ErrorCode.java @@ -6,19 +6,19 @@ @Getter @AllArgsConstructor public enum ErrorCode { - TEST_ERROR_CODE(400, "응답 테스트 실패입니다."), - DEVICE_ALREADY_REGISTERED(400, "이미 등록된 디바이스입니다."), - MEMBER_NOT_FOUND(404, "해당 사용자를 찾을 수 없습니다."), - INVALID_CHALLENGE(401, "challenge 값이 유효하지 않습니다."), - INVALID_SIGNATURE(401, "서명 검증에 실패했습니다."), - EXPIRED_TOKEN(401, "토큰이 만료되었습니다."), - INVALID_TOKEN(401, "유효하지 않은 토큰입니다."), - INVALID_REFRESH_TOKEN(401, "Refresh Token이 유효하지 않습니다."), - UNSUPPORTED_TOKEN(401,"지원하지 않는 토큰입니다."), - FASTAPI_COMMUNICATION_ERROR(500, "FastAPI와의 통신 중 오류가 발생했습니다."), + TEST_ERROR_CODE(400, "응답 테스트 실패입니다."), + DEVICE_ALREADY_REGISTERED(400, "이미 등록된 디바이스입니다."), + MEMBER_NOT_FOUND(404, "해당 사용자를 찾을 수 없습니다."), + INVALID_CHALLENGE(401, "challenge 값이 유효하지 않습니다."), + INVALID_SIGNATURE(401, "서명 검증에 실패했습니다."), + EXPIRED_TOKEN(401, "토큰이 만료되었습니다."), + INVALID_TOKEN(401, "유효하지 않은 토큰입니다."), + INVALID_REFRESH_TOKEN(401, "Refresh Token이 유효하지 않습니다."), + UNSUPPORTED_TOKEN(401, "지원하지 않는 토큰입니다."), + FASTAPI_COMMUNICATION_ERROR(500, "FastAPI와의 통신 중 오류가 발생했습니다."), + FILE_NOT_FOUND(400, "입력 파일이 없습니다."), + FILE_READ_ERROR(500, "서버에서 파일을 읽어들이는 중 오류가 발생했습니다."); - ; - - private final int code; - private final String message; + private final int code; + private final String message; } diff --git a/src/main/java/com/api/sss/login/dto/request/BiometricLoginVerifyRequest.java b/src/main/java/com/api/sss/login/dto/request/BiometricLoginVerifyRequest.java index 71a8a4c..12b7c96 100644 --- a/src/main/java/com/api/sss/login/dto/request/BiometricLoginVerifyRequest.java +++ b/src/main/java/com/api/sss/login/dto/request/BiometricLoginVerifyRequest.java @@ -1,5 +1,6 @@ package com.api.sss.login.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Getter; @@ -7,6 +8,7 @@ public class BiometricLoginVerifyRequest { @NotBlank(message = "deviceId는 필수입니다.") + @Schema(description = "기기 고유 ID", example = "abc123device") private String deviceId; @NotBlank(message = "challenge는 필수입니다.") diff --git a/src/main/java/com/api/sss/model/service/ModelService.java b/src/main/java/com/api/sss/model/service/ModelService.java index b0a7add..ddf9cc9 100644 --- a/src/main/java/com/api/sss/model/service/ModelService.java +++ b/src/main/java/com/api/sss/model/service/ModelService.java @@ -1,58 +1,87 @@ package com.api.sss.model.service; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + import com.api.sss.config.exception.CustomException; import com.api.sss.config.exception.ErrorCode; import com.api.sss.model.dto.request.ChatAskRequest; -import com.api.sss.model.dto.response.ChatAskResponse; import com.api.sss.model.dto.request.NewsRequest; +import com.api.sss.model.dto.response.ChatAskResponse; import com.api.sss.model.dto.response.NewsResponse; -import org.springframework.http.*; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.util.List; @Service public class ModelService { - private final RestTemplate restTemplate = new RestTemplate(); - private final String FASTAPI_URL = "http://203.153.147.12:5050"; - - public List cardNews(NewsRequest request) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity entity = new HttpEntity<>(request, headers); - - try { - ResponseEntity response = restTemplate.exchange( - FASTAPI_URL + "/news", - HttpMethod.POST, - entity, - NewsResponse.class - ); - return response.getBody().getResults(); - } catch (Exception e) { - throw new CustomException(ErrorCode.FASTAPI_COMMUNICATION_ERROR); - } - } - - public ChatAskResponse askQuestion(ChatAskRequest request) { - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - - HttpEntity entity = new HttpEntity<>(request, headers); - - try { - ResponseEntity response = restTemplate.exchange( - FASTAPI_URL + "/chat", - HttpMethod.POST, - entity, - ChatAskResponse.class - ); - return response.getBody(); - } catch (Exception e) { - throw new CustomException(ErrorCode.FASTAPI_COMMUNICATION_ERROR); - } - } + private final RestTemplate restTemplate = new RestTemplate(); + private final String FASTAPI_URL = "http://203.153.147.12:5050"; + + public List cardNews(NewsRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(request, headers); + + try { + ResponseEntity response = restTemplate.exchange( + FASTAPI_URL + "/news", + HttpMethod.POST, + entity, + NewsResponse.class + ); + return response.getBody().getResults(); + } catch (Exception e) { + throw new CustomException(ErrorCode.FASTAPI_COMMUNICATION_ERROR); + } + } + + public ChatAskResponse askQuestion(ChatAskRequest request) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(request, headers); + + try { + ResponseEntity response = restTemplate.exchange( + FASTAPI_URL + "/chat", + HttpMethod.POST, + entity, + ChatAskResponse.class + ); + return response.getBody(); + } catch (Exception e) { + throw new CustomException(ErrorCode.FASTAPI_COMMUNICATION_ERROR); + } + } + + public String predictCoin(String coinName) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + Map body = new HashMap<>(); + body.put("coinName", coinName); + HttpEntity> entity = new HttpEntity<>(body, headers); + + try { + // TODO: return response.getBody(); + // ResponseEntity response = restTemplate.exchange( + // FASTAPI_URL + "/chat/predict", + // HttpMethod.POST, + // entity, + // String.class + // ); + return coinName; + } catch (Exception e) { + throw new CustomException(ErrorCode.FASTAPI_COMMUNICATION_ERROR); + } + } } diff --git a/src/main/java/com/api/sss/stt/controller/SttController.java b/src/main/java/com/api/sss/stt/controller/SttController.java new file mode 100644 index 0000000..91386cb --- /dev/null +++ b/src/main/java/com/api/sss/stt/controller/SttController.java @@ -0,0 +1,56 @@ +package com.api.sss.stt.controller; + +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.api.sss.config.response.dto.CustomResponse; +import com.api.sss.config.response.dto.SuccessStatus; +import com.api.sss.stt.dto.response.ChatPredictResponse; +import com.api.sss.stt.service.SttService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class SttController { + + private final SttService sttService; + + @Operation( + summary = "코인 시세 예측 API", + description = "STT로 코인종목 명을 입력받아 FastAPI 서비스에 전달하고 답변을 받아옵니다." + ) + @ApiResponse(responseCode = "200", description = "답변 수신 성공") + @ApiResponse( + responseCode = "500", + description = "FastAPI 서비스 오류 또는 통신 실패", + content = @Content(mediaType = "application/json", examples = @ExampleObject(value = """ + { + "code": 500, + "message": "FastAPI와의 통신 중 오류가 발생했습니다." + } + """)) + // 종목명 인식 실패했을 시 response 추가 필요 + ) + @PostMapping(path = "/chat/predict", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CustomResponse predictCoin( + @Parameter( + description = "업로드할 44100Hz mp3 음성 파일", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE) + ) + @RequestParam("file") MultipartFile file) { + ChatPredictResponse response = sttService.predictCoin(file); + return CustomResponse.success(response, SuccessStatus.SUCCESS); + } + +} diff --git a/src/main/java/com/api/sss/stt/dto/response/ChatPredictResponse.java b/src/main/java/com/api/sss/stt/dto/response/ChatPredictResponse.java new file mode 100644 index 0000000..c21ce81 --- /dev/null +++ b/src/main/java/com/api/sss/stt/dto/response/ChatPredictResponse.java @@ -0,0 +1,12 @@ +package com.api.sss.stt.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@AllArgsConstructor +public class ChatPredictResponse { + private String prediction; +} diff --git a/src/main/java/com/api/sss/stt/service/SttService.java b/src/main/java/com/api/sss/stt/service/SttService.java new file mode 100644 index 0000000..36169b7 --- /dev/null +++ b/src/main/java/com/api/sss/stt/service/SttService.java @@ -0,0 +1,74 @@ +package com.api.sss.stt.service; + +import java.io.IOException; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; + +import com.api.sss.config.exception.CustomException; +import com.api.sss.config.exception.ErrorCode; +import com.api.sss.model.service.ModelService; +import com.api.sss.stt.dto.response.ChatPredictResponse; +import com.google.cloud.speech.v1.RecognitionAudio; +import com.google.cloud.speech.v1.RecognitionConfig; +import com.google.cloud.speech.v1.RecognizeResponse; +import com.google.cloud.speech.v1.SpeechClient; +import com.google.cloud.speech.v1.SpeechRecognitionAlternative; +import com.google.cloud.speech.v1.SpeechRecognitionResult; +import com.google.protobuf.ByteString; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SttService { + + private final SpeechClient speechClient; + private final ModelService modelService; + + public ChatPredictResponse predictCoin(@RequestParam("file") MultipartFile file) { + String coinName = transcribe(file); + + return ChatPredictResponse.builder() + .prediction(modelService.predictCoin(coinName)) + .build(); + } + + public String transcribe(MultipartFile file) { + if (file.isEmpty()) { + throw new CustomException(ErrorCode.FILE_NOT_FOUND); + } + + try { + ByteString audioBytes = ByteString.copyFrom(file.getBytes()); + + RecognitionAudio audio = RecognitionAudio.newBuilder() + .setContent(audioBytes) + .build(); + + RecognitionConfig config = RecognitionConfig.newBuilder() + .setEncoding(RecognitionConfig.AudioEncoding.MP3) + .setSampleRateHertz(44100) + .setLanguageCode("ko-KR") + .build(); + + RecognizeResponse response = speechClient.recognize(config, audio); + + List results = response.getResultsList(); + StringBuilder transcription = new StringBuilder(); + + for (SpeechRecognitionResult result : results) { + SpeechRecognitionAlternative alternative = result.getAlternativesList().get(0); + transcription.append(alternative.getTranscript()); + } + + return transcription.toString(); + } catch (IOException e) { + throw new CustomException(ErrorCode.FILE_NOT_FOUND); + } + + } + +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 8efe9b5..8f263fa 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -18,6 +18,11 @@ spring: format_sql: true database-platform: org.hibernate.dialect.MySQL8Dialect + cloud: + gcp: + credentials: + encoded-key: ${GCP_STT_KEY} + jwt: secret: ${JWT_SECRET} access-expiration: ${ACCESS_EXPIRATION} # 1시간 (ms)