From 32a5c8b995ce5a3131c4176e05504ee9e99c7b64 Mon Sep 17 00:00:00 2001 From: sujeongmoon Date: Fri, 14 Nov 2025 01:26:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?chore:#24=20GoogleCloudConfig=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/deploy.yml | 3 +- Dockerfile | 2 +- build.gradle | 3 ++ docker-compose.yml | 5 +-- .../com/api/sss/config/GoogleCloudConfig.java | 43 +++++++++++++++++++ src/main/resources/application-prod.yml | 5 +++ 6 files changed, 55 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/api/sss/config/GoogleCloudConfig.java 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..751784f --- /dev/null +++ b/src/main/java/com/api/sss/config/GoogleCloudConfig.java @@ -0,0 +1,43 @@ +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 org.springframework.context.annotation.Profile; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.speech.v1.SpeechClient; +import com.google.cloud.speech.v1.SpeechSettings; +import com.google.cloud.speech.v1.stub.SpeechStubSettings; + +@Configuration +public class GoogleCloudConfig { + + // encoded-key (base64 문자열) 그대로 주입 + 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/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) From 9909658a1183ce4756d183868867c54f027611f3 Mon Sep 17 00:00:00 2001 From: sujeongmoon Date: Tue, 25 Nov 2025 02:47:57 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:#24=20STT=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=BD=94=EC=9D=B8=EC=A2=85=EB=AA=A9?= =?UTF-8?q?=EB=AA=85=20=EC=9E=85=EB=A0=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/api/sss/config/GoogleCloudConfig.java | 5 +- .../api/sss/config/exception/ErrorCode.java | 28 +++---- .../api/sss/stt/controller/SttController.java | 56 ++++++++++++++ .../stt/dto/response/ChatPredictResponse.java | 12 +++ .../com/api/sss/stt/service/SttService.java | 74 +++++++++++++++++++ 5 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 src/main/java/com/api/sss/stt/controller/SttController.java create mode 100644 src/main/java/com/api/sss/stt/dto/response/ChatPredictResponse.java create mode 100644 src/main/java/com/api/sss/stt/service/SttService.java diff --git a/src/main/java/com/api/sss/config/GoogleCloudConfig.java b/src/main/java/com/api/sss/config/GoogleCloudConfig.java index 751784f..66df523 100644 --- a/src/main/java/com/api/sss/config/GoogleCloudConfig.java +++ b/src/main/java/com/api/sss/config/GoogleCloudConfig.java @@ -7,21 +7,18 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Profile; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.speech.v1.SpeechClient; import com.google.cloud.speech.v1.SpeechSettings; -import com.google.cloud.speech.v1.stub.SpeechStubSettings; @Configuration public class GoogleCloudConfig { - // encoded-key (base64 문자열) 그대로 주입 private final GoogleCredentials googleCredentials; public GoogleCloudConfig( - @Value("${spring.cloud.gcp.credentials.encoded-key:}") + @Value("${spring.cloud.gcp.credentials.encoded-key}") String encodedKey ) throws IOException { byte[] decoded = Base64.getDecoder().decode(encodedKey); 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/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..505b2ee --- /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; + + public ChatPredictResponse predictCoin(@RequestParam("file") MultipartFile file) { + String coinName = transcribe(file); + // 임시 종목명 리턴 중 (FastAPI 미구현) + return ChatPredictResponse.builder() + .prediction(coinName) + .build(); + // return 진짜값으로 변경 필요 + } + + 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); + } + + } + +} From 289f270be5f0d23bd647eef9dbe607508eb42f1f Mon Sep 17 00:00:00 2001 From: sujeongmoon Date: Tue, 25 Nov 2025 03:20:14 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat:#24=20=EC=BD=94=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=84=B8=EB=A5=BC=20=EC=98=88=EC=B8=A1=ED=95=98=EB=8A=94=20FAS?= =?UTF-8?q?TAPI=20=ED=98=B8=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/BiometricLoginVerifyRequest.java | 2 + .../api/sss/model/service/ModelService.java | 121 +++++++++++------- .../com/api/sss/stt/service/SttService.java | 6 +- 3 files changed, 80 insertions(+), 49 deletions(-) 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/service/SttService.java b/src/main/java/com/api/sss/stt/service/SttService.java index 505b2ee..36169b7 100644 --- a/src/main/java/com/api/sss/stt/service/SttService.java +++ b/src/main/java/com/api/sss/stt/service/SttService.java @@ -26,14 +26,14 @@ public class SttService { private final SpeechClient speechClient; + private final ModelService modelService; public ChatPredictResponse predictCoin(@RequestParam("file") MultipartFile file) { String coinName = transcribe(file); - // 임시 종목명 리턴 중 (FastAPI 미구현) + return ChatPredictResponse.builder() - .prediction(coinName) + .prediction(modelService.predictCoin(coinName)) .build(); - // return 진짜값으로 변경 필요 } public String transcribe(MultipartFile file) {