From ed5baf83079cb14e061e0e0c1c165d3f192134e9 Mon Sep 17 00:00:00 2001 From: 99hyuk Date: Thu, 22 May 2025 17:44:21 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[FEAT]=20=ED=86=B5=EC=97=AD=20AI=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(OpenAI,=20Google=20STT,=20Google=20Transl?= =?UTF-8?q?ate=203=EA=B0=80=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 +- build.gradle | 18 ++ .../GoogleSttSocketHandler.java | 160 +++++++++++++++++ .../googleSttPlusGpt/GptTtsService.java | 86 ++++++++++ .../googleSttPlusGpt/WebSocketConfig.java | 17 ++ .../GoogleSttTranslateSocketHandler.java | 109 ++++++++++++ .../GoogleTranslateTtsService.java | 119 +++++++++++++ .../googleTrans/WebSocketConfig.java | 23 +++ .../webSocket/ByteArrayMultipartFile.java | 61 +++++++ .../webSocket/TranslateSocketHandler.java | 33 ++++ .../translate/webSocket/WebSocketConfig.java | 24 +++ .../ai/translate/whisper/OpenAiService.java | 162 ++++++++++++++++++ .../whisper/TranslateController.java | 24 +++ 13 files changed, 839 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/GoogleSttSocketHandler.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/GptTtsService.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/WebSocketConfig.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/googleTrans/GoogleSttTranslateSocketHandler.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/googleTrans/GoogleTranslateTtsService.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/googleTrans/WebSocketConfig.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/webSocket/ByteArrayMultipartFile.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/webSocket/TranslateSocketHandler.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/webSocket/WebSocketConfig.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/whisper/OpenAiService.java create mode 100644 src/main/java/com/onebridge/ouch/ai/translate/whisper/TranslateController.java diff --git a/.gitignore b/.gitignore index 71ee9e4..3727504 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,6 @@ out/ .vscode/ ### application.properties ### -src/main/resources/application-local.yml \ No newline at end of file +src/main/resources/application-local.yml + +src/main/resources/google-service-key.json \ No newline at end of file diff --git a/build.gradle b/build.gradle index f7826c4..22d23d4 100644 --- a/build.gradle +++ b/build.gradle @@ -56,6 +56,24 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + //websocket + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + //Java용 gRPC 클라이언트, Google STT Streaming + //implementation 'com.google.cloud:google-cloud-speech:4.38.0' + + //implementation 'com.fasterxml.jackson.core:jackson-databind' + + //BOM으로 Google Cloud 라이브러리 버전 일괄 관리 + implementation platform('com.google.cloud:libraries-bom:26.60.0') + // gRPC 스트리밍 번역 + implementation 'com.google.api.grpc:grpc-google-cloud-translate-v3:2.63.0' + // --- STT, Translation, TTS 클라이언트 --- + implementation 'com.google.cloud:google-cloud-speech' // Speech-to-Text + implementation 'com.google.cloud:google-cloud-translate' // Translation API (REST) + implementation 'com.google.cloud:google-cloud-texttospeech' // Cloud Text-to-Speech } tasks.named('test') { diff --git a/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/GoogleSttSocketHandler.java b/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/GoogleSttSocketHandler.java new file mode 100644 index 0000000..1aada3f --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/GoogleSttSocketHandler.java @@ -0,0 +1,160 @@ +// package com.onebridge.ouch.ai.translate.googleSttPlusGpt; +// +// import java.io.IOException; +// import java.io.InputStream; +// import java.util.List; +// import java.util.concurrent.ConcurrentHashMap; +// +// import jakarta.annotation.PostConstruct; +// import org.springframework.beans.factory.annotation.Value; +// import org.springframework.core.io.Resource; +// import org.springframework.stereotype.Component; +// import org.springframework.web.socket.BinaryMessage; +// import org.springframework.web.socket.CloseStatus; +// import org.springframework.web.socket.TextMessage; +// import org.springframework.web.socket.WebSocketSession; +// import org.springframework.web.socket.handler.BinaryWebSocketHandler; +// +// import com.google.api.gax.core.FixedCredentialsProvider; +// import com.google.auth.oauth2.GoogleCredentials; +// import com.google.api.gax.rpc.ClientStream; +// import com.google.api.gax.rpc.ResponseObserver; +// import com.google.api.gax.rpc.StreamController; +// import com.google.cloud.speech.v1.RecognitionConfig; +// import com.google.cloud.speech.v1.StreamingRecognitionConfig; +// import com.google.cloud.speech.v1.StreamingRecognizeRequest; +// import com.google.cloud.speech.v1.StreamingRecognizeResponse; +// import com.google.cloud.speech.v1.StreamingRecognitionResult; +// import com.google.cloud.speech.v1.SpeechClient; +// import com.google.cloud.speech.v1.SpeechSettings; +// import com.google.protobuf.ByteString; +// +// @Component +// public class GoogleSttSocketHandler extends BinaryWebSocketHandler { +// +// @Value("${google.application.credentials}") +// private Resource googleCredentialResource; +// +// private SpeechClient speechClient; +// private final ConcurrentHashMap> clientStreams = new ConcurrentHashMap<>(); +// private final GptTtsService gptTtsService; +// +// public GoogleSttSocketHandler(GptTtsService gptTtsService) { +// this.gptTtsService = gptTtsService; +// } +// +// @PostConstruct +// public void init() throws IOException { +// // 서비스 계정 JSON을 직접 로드해 명시적 자격 증명 설정 +// try (InputStream is = googleCredentialResource.getInputStream()) { +// GoogleCredentials creds = GoogleCredentials.fromStream(is) +// .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform")); +// +// SpeechSettings settings = SpeechSettings.newBuilder() +// .setCredentialsProvider(FixedCredentialsProvider.create(creds)) +// .build(); +// +// speechClient = SpeechClient.create(settings); +// } +// } +// +// @Override +// public void afterConnectionEstablished(WebSocketSession session) { +// System.out.println("[서버] WebSocket 연결됨: " + session.getId()); +// try { +// // STT 설정 +// RecognitionConfig recogConfig = RecognitionConfig.newBuilder() +// .setEncoding(RecognitionConfig.AudioEncoding.WEBM_OPUS) +// .setSampleRateHertz(48000) +// .setLanguageCode("ko-KR") +// .build(); +// +// StreamingRecognitionConfig streamingConfig = StreamingRecognitionConfig.newBuilder() +// .setConfig(recogConfig) +// .setInterimResults(true) +// .build(); +// +// // STT 스트림 시작 +// ClientStream clientStream = +// speechClient.streamingRecognizeCallable().splitCall(new ResponseObserver() { +// @Override +// public void onStart(StreamController controller) { +// System.out.println("[서버] STT Streaming 시작 (세션: " + session.getId() + ")"); +// } +// +// @Override +// public void onResponse(StreamingRecognizeResponse response) { +// for (StreamingRecognitionResult result : response.getResultsList()) { +// if (!result.getIsFinal()) continue; +// +// String transcript = result.getAlternatives(0).getTranscript().trim(); +// if (transcript.isEmpty()) continue; +// +// try { +// byte[] audioBytes = gptTtsService.translateAndSynthesize(transcript); +// session.sendMessage(new BinaryMessage(audioBytes)); +// } catch (Exception e) { +// try { +// session.sendMessage(new TextMessage("TTS 처리 중 오류가 발생했습니다.")); +// } catch (IOException ioe) { +// System.err.println("[서버] 클라이언트 알림 전송 오류: " + ioe.getMessage()); +// } +// } +// } +// } +// +// @Override +// public void onError(Throwable t) { +// System.err.println("[서버] STT 오류 (세션: " + session.getId() + "): " + t.getMessage()); +// try { +// session.sendMessage(new TextMessage("STT 처리 중 오류가 발생했습니다.")); +// } catch (IOException ioe) { +// System.err.println("[서버] 클라이언트 알림 전송 오류: " + ioe.getMessage()); +// } +// } +// +// @Override +// public void onComplete() { +// System.out.println("[서버] STT Streaming 종료 (세션: " + session.getId() + ")"); +// // 세션은 클라이언트가 stop할 때까지 유지 +// } +// }); +// +// // 초기 설정 전송 +// clientStream.send(StreamingRecognizeRequest.newBuilder() +// .setStreamingConfig(streamingConfig) +// .build()); +// +// clientStreams.put(session.getId(), clientStream); +// +// } catch (Exception e) { +// e.printStackTrace(); +// try { +// session.close(CloseStatus.SERVER_ERROR); +// } catch (IOException ioe) { +// System.err.println("[서버] 세션 종료 오류: " + ioe.getMessage()); +// } +// } +// } +// +// @Override +// protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { +// ClientStream clientStream = clientStreams.get(session.getId()); +// if (clientStream != null) { +// try { +// clientStream.send(StreamingRecognizeRequest.newBuilder() +// .setAudioContent(ByteString.copyFrom(message.getPayload().array())) +// .build()); +// } catch (Exception e) { +// System.err.println("[서버] 오디오 청크 전송 오류 (세션: " + session.getId() + "): " + e.getMessage()); +// } +// } +// } +// +// @Override +// public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { +// System.out.println("[서버] WebSocket 종료: " + session.getId() + " (" + status + ")"); +// ClientStream stream = clientStreams.remove(session.getId()); +// if (stream != null) stream.closeSend(); +// } +// } diff --git a/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/GptTtsService.java b/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/GptTtsService.java new file mode 100644 index 0000000..b1c5f23 --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/GptTtsService.java @@ -0,0 +1,86 @@ +// package com.onebridge.ouch.ai.translate.googleSttPlusGpt; +// +// import org.springframework.beans.factory.annotation.Value; +// 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.fasterxml.jackson.databind.JsonNode; +// import com.fasterxml.jackson.databind.ObjectMapper; +// +// @Service +// public class GptTtsService { +// @Value("${openai.api-key}") +// private String openAiApiKey; +// +// private final RestTemplate restTemplate = new RestTemplate(); +// +// public byte[] translateAndSynthesize(String text) { +// String translatedText = translateText(text); +// return textToSpeech(translatedText); +// } +// +// private String translateText(String text) { +// String url = "https://api.openai.com/v1/chat/completions"; +// HttpHeaders headers = new HttpHeaders(); +// headers.setBearerAuth(openAiApiKey); +// headers.setContentType(MediaType.APPLICATION_JSON); +// +// String systemPrompt = "Detect the language and translate the following text to Korean if it is not Korean, or to English if it is Korean. Output only the translation."; +// String reqBody = String.format(""" +// { +// "model": "gpt-4o", +// "messages": [ +// {"role": "system", "content": "%s"}, +// {"role": "user", "content": "%s"} +// ], +// "max_tokens": 512 +// } +// """, systemPrompt, text.replace("\"", "\\\"")); +// +// HttpEntity requestEntity = new HttpEntity<>(reqBody, headers); +// ResponseEntity response = restTemplate.postForEntity(url, requestEntity, String.class); +// if (!response.getStatusCode().is2xxSuccessful()) { +// throw new RuntimeException("번역 API 실패: " + response.getBody()); +// } +// +// // GPT 응답 JSON 파싱 (Jackson 등 사용) +// try { +// ObjectMapper mapper = new ObjectMapper(); +// JsonNode root = mapper.readTree(response.getBody()); +// return root.path("choices").get(0).path("message").path("content").asText().trim(); +// } catch (Exception e) { +// throw new RuntimeException("GPT 응답 파싱 오류", e); +// } +// } +// +// private byte[] textToSpeech(String text) { +// String url = "https://api.openai.com/v1/audio/speech"; +// HttpHeaders headers = new HttpHeaders(); +// headers.setBearerAuth(openAiApiKey); +// headers.setContentType(MediaType.APPLICATION_JSON); +// +// String reqBody = String.format(""" +// { +// "model": "tts-1", +// "input": "%s", +// "voice": "nova", +// "response_format": "opus" +// } +// """, text.replace("\"", "\\\"")); +// +// HttpEntity requestEntity = new HttpEntity<>(reqBody, headers); +// ResponseEntity response = restTemplate.exchange( +// url, HttpMethod.POST, requestEntity, byte[].class +// ); +// +// if (!response.getStatusCode().is2xxSuccessful()) { +// throw new RuntimeException("TTS API 실패: " + response.getBody()); +// } +// return response.getBody(); +// } +// } diff --git a/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/WebSocketConfig.java b/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/WebSocketConfig.java new file mode 100644 index 0000000..5ef5b2d --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/googleSttPlusGpt/WebSocketConfig.java @@ -0,0 +1,17 @@ +// package com.onebridge.ouch.ai.translate.googleSttPlusGpt; +// +// import org.springframework.context.annotation.Configuration; +// import org.springframework.beans.factory.annotation.Autowired; +// import org.springframework.web.socket.config.annotation.*; +// +// @Configuration +// @EnableWebSocket +// public class WebSocketConfig implements WebSocketConfigurer { +// @Autowired +// private GoogleSttSocketHandler googleSttSocketHandler; +// +// @Override +// public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { +// registry.addHandler(googleSttSocketHandler, "/ws/stt").setAllowedOrigins("*"); +// } +// } diff --git a/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/GoogleSttTranslateSocketHandler.java b/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/GoogleSttTranslateSocketHandler.java new file mode 100644 index 0000000..4f71dfe --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/GoogleSttTranslateSocketHandler.java @@ -0,0 +1,109 @@ +package com.onebridge.ouch.ai.translate.googleTrans; + +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.stereotype.Component; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.BinaryWebSocketHandler; + +import com.google.api.gax.rpc.ClientStream; +import com.google.api.gax.rpc.ResponseObserver; +import com.google.api.gax.rpc.StreamController; +import com.google.cloud.speech.v1.RecognitionConfig; +import com.google.cloud.speech.v1.RecognitionConfig.AudioEncoding; +import com.google.cloud.speech.v1.SpeechClient; +import com.google.cloud.speech.v1.SpeechSettings; +import com.google.cloud.speech.v1.StreamingRecognizeRequest; +import com.google.cloud.speech.v1.StreamingRecognitionConfig; +import com.google.cloud.speech.v1.StreamingRecognizeResponse; +import com.google.cloud.speech.v1.StreamingRecognitionResult; +import com.google.protobuf.ByteString; + +@Component +public class GoogleSttTranslateSocketHandler extends BinaryWebSocketHandler { + + // 이제 이 한 가지만 주입합니다. + private final GoogleTranslateTtsService translateService; + + // 세션별 STT 스트림 저장소 + private final Map> streams = new ConcurrentHashMap<>(); + + public GoogleSttTranslateSocketHandler(GoogleTranslateTtsService translateService) { + this.translateService = translateService; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + try { + // 1) SpeechClient 생성 (인증은 서비스 레이어에서 이미 처리됨) + SpeechSettings speechSettings = SpeechSettings.newBuilder().build(); + SpeechClient speechClient = SpeechClient.create(speechSettings); + + // 2) STT 설정: 한국어 기본, 영어 자동 감지 + RecognitionConfig recogConfig = RecognitionConfig.newBuilder() + .setEncoding(AudioEncoding.WEBM_OPUS) + .setSampleRateHertz(48000) + .setLanguageCode("ko-KR") + .addAllAlternativeLanguageCodes(List.of("en-US")) + .build(); + + StreamingRecognitionConfig streamingConfig = StreamingRecognitionConfig.newBuilder() + .setConfig(recogConfig) + .setInterimResults(false) // 중간 결과 무시 + .build(); + + // 3) 스트리밍 시작 + ClientStream clientStream = + speechClient.streamingRecognizeCallable() + .splitCall(new ResponseObserver() { + @Override public void onStart(StreamController controller) { } + @Override public void onError(Throwable t) { t.printStackTrace(); } + @Override public void onComplete() { } + + @Override + public void onResponse(StreamingRecognizeResponse resp) { + for (StreamingRecognitionResult result : resp.getResultsList()) { + if (!result.getIsFinal()) continue; + String text = result.getAlternatives(0).getTranscript().trim(); + // 숫자나 너무 짧은 건 필터 + if (text.matches("^\\d+$") || text.length() <= 2) continue; + // ▶ Google Translate API + Cloud TTS 파이프라인으로 처리 + translateService.translateAndSynthesizeToSession(text, session); + } + } + }); + + // 4) 최초 config 요청 + clientStream.send( + StreamingRecognizeRequest.newBuilder() + .setStreamingConfig(streamingConfig) + .build() + ); + streams.put(session.getId(), clientStream); + + } catch (Exception e) { + try { session.close(CloseStatus.SERVER_ERROR); } catch (IOException ignore) {} + } + } + + @Override + protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { + ClientStream stream = streams.get(session.getId()); + if (stream != null) { + stream.send(StreamingRecognizeRequest.newBuilder() + .setAudioContent(ByteString.copyFrom(message.getPayload().array())) + .build()); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + ClientStream stream = streams.remove(session.getId()); + if (stream != null) stream.closeSend(); + } +} diff --git a/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/GoogleTranslateTtsService.java b/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/GoogleTranslateTtsService.java new file mode 100644 index 0000000..aebcb19 --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/GoogleTranslateTtsService.java @@ -0,0 +1,119 @@ +package com.onebridge.ouch.ai.translate.googleTrans; + +import java.io.IOException; +import java.io.InputStream; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.WebSocketSession; + +import com.google.api.gax.core.FixedCredentialsProvider; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.ServiceOptions; +import com.google.cloud.texttospeech.v1.AudioConfig; +import com.google.cloud.texttospeech.v1.AudioEncoding; +import com.google.cloud.texttospeech.v1.SsmlVoiceGender; +import com.google.cloud.texttospeech.v1.SynthesisInput; +import com.google.cloud.texttospeech.v1.SynthesizeSpeechResponse; +import com.google.cloud.texttospeech.v1.TextToSpeechClient; +import com.google.cloud.texttospeech.v1.TextToSpeechSettings; +import com.google.cloud.texttospeech.v1.VoiceSelectionParams; +import com.google.cloud.translate.v3.LocationName; +import com.google.cloud.translate.v3.TranslateTextRequest; +import com.google.cloud.translate.v3.TranslateTextResponse; +import com.google.cloud.translate.v3.TranslationServiceClient; +import com.google.cloud.translate.v3.TranslationServiceSettings; + +import jakarta.annotation.PostConstruct; + +@Service +public class GoogleTranslateTtsService { + + @Value("${google.application.credentials}") + private Resource credential; + + private TranslationServiceClient translateClient; + private TextToSpeechClient ttsClient; + private String projectId; + private final String location = "global"; + + @PostConstruct + public void init() throws IOException { + // 1) 서비스 계정 JSON 로드 + try (InputStream is = credential.getInputStream()) { + GoogleCredentials creds = GoogleCredentials.fromStream(is) + .createScoped(List.of("https://www.googleapis.com/auth/cloud-platform")); + FixedCredentialsProvider cap = FixedCredentialsProvider.create(creds); + + // 2) TranslationServiceClient 초기화 + TranslationServiceSettings trSettings = TranslationServiceSettings.newBuilder() + .setCredentialsProvider(cap) + .build(); + translateClient = TranslationServiceClient.create(trSettings); + + // 3) TextToSpeechClient 초기화 + TextToSpeechSettings ttsSettings = TextToSpeechSettings.newBuilder() + .setCredentialsProvider(cap) + .build(); + ttsClient = TextToSpeechClient.create(ttsSettings); + + // 4) 프로젝트 ID 자동 조회 (환경변수 또는 메타데이터) + projectId = ServiceOptions.getDefaultProjectId(); + } + } + + /** + * STT 핸들러에서 인식된 텍스트를 번역 → TTS 합성 → WebSocketSession 으로 전송합니다. + */ + public void translateAndSynthesizeToSession(String text, WebSocketSession session) { + // 1) 원본 언어 자동 감지 (한글이 있으면 ko → en, 그렇지 않으면 en → ko) + boolean isKorean = containsKorean(text); + String sourceLang = isKorean ? "ko" : "en"; + String targetLang = isKorean ? "en" : "ko"; + + // 2) 번역 요청 + LocationName parent = LocationName.of(projectId, location); + TranslateTextRequest trReq = TranslateTextRequest.newBuilder() + .setParent(parent.toString()) + .setSourceLanguageCode(sourceLang) + .setTargetLanguageCode(targetLang) + .addContents(text) + .build(); + TranslateTextResponse trResp = translateClient.translateText(trReq); + String translatedText = trResp.getTranslations(0).getTranslatedText(); + + // 3) TTS 합성 요청 + SynthesisInput input = SynthesisInput.newBuilder() + .setText(translatedText) + .build(); + VoiceSelectionParams voice = VoiceSelectionParams.newBuilder() + .setLanguageCode(targetLang.equals("ko") ? "ko-KR" : "en-US") + .setSsmlGender(SsmlVoiceGender.NEUTRAL) + .build(); + AudioConfig audioConfig = AudioConfig.newBuilder() + .setAudioEncoding(AudioEncoding.MP3) + .build(); + SynthesizeSpeechResponse ttsResp = ttsClient.synthesizeSpeech(input, voice, audioConfig); + byte[] audioBytes = ttsResp.getAudioContent().toByteArray(); + + // 4) WebSocket으로 전송 + try { + session.sendMessage(new BinaryMessage(audioBytes)); + } catch (IOException e) { + // 전송 중 에러 로깅 + System.err.println("TTS 전송 실패: " + e.getMessage()); + } + } + + /** 한글 글자가 하나라도 포함됐는지 검사 */ + private boolean containsKorean(String text) { + return text.codePoints().anyMatch(cp -> + (cp >= 0xAC00 && cp <= 0xD7AF) // 한글 완성형 + || (cp >= 0x1100 && cp <= 0x11FF) // 한글 자모 + || (cp >= 0x3130 && cp <= 0x318F) // 한글 호환 자모 + ); + } +} diff --git a/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/WebSocketConfig.java b/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/WebSocketConfig.java new file mode 100644 index 0000000..4185133 --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/googleTrans/WebSocketConfig.java @@ -0,0 +1,23 @@ +package com.onebridge.ouch.ai.translate.googleTrans; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +public class WebSocketConfig implements WebSocketConfigurer { + + private final GoogleSttTranslateSocketHandler googleHandler; + + public WebSocketConfig(GoogleSttTranslateSocketHandler googleHandler) { + this.googleHandler = googleHandler; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(googleHandler, "/ws/google-stt") + .setAllowedOrigins("*"); + } +} \ No newline at end of file diff --git a/src/main/java/com/onebridge/ouch/ai/translate/webSocket/ByteArrayMultipartFile.java b/src/main/java/com/onebridge/ouch/ai/translate/webSocket/ByteArrayMultipartFile.java new file mode 100644 index 0000000..732759a --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/webSocket/ByteArrayMultipartFile.java @@ -0,0 +1,61 @@ +// package com.onebridge.ouch.ai.translate; +// +// import org.springframework.web.multipart.MultipartFile; +// import java.io.*; +// +// public class ByteArrayMultipartFile implements MultipartFile { +// +// private final byte[] content; +// private final String name; +// private final String originalFilename; +// private final String contentType; +// +// public ByteArrayMultipartFile(byte[] content, String name, String originalFilename, String contentType) { +// this.content = content; +// this.name = name; +// this.originalFilename = originalFilename; +// this.contentType = contentType; +// } +// +// @Override +// public String getName() { +// return name; +// } +// +// @Override +// public String getOriginalFilename() { +// return originalFilename; +// } +// +// @Override +// public String getContentType() { +// return contentType; +// } +// +// @Override +// public boolean isEmpty() { +// return content == null || content.length == 0; +// } +// +// @Override +// public long getSize() { +// return content.length; +// } +// +// @Override +// public byte[] getBytes() throws IOException { +// return content; +// } +// +// @Override +// public InputStream getInputStream() throws IOException { +// return new ByteArrayInputStream(content); +// } +// +// @Override +// public void transferTo(File dest) throws IOException, IllegalStateException { +// try (FileOutputStream out = new FileOutputStream(dest)) { +// out.write(content); +// } +// } +// } diff --git a/src/main/java/com/onebridge/ouch/ai/translate/webSocket/TranslateSocketHandler.java b/src/main/java/com/onebridge/ouch/ai/translate/webSocket/TranslateSocketHandler.java new file mode 100644 index 0000000..d9792cc --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/webSocket/TranslateSocketHandler.java @@ -0,0 +1,33 @@ +// package com.onebridge.ouch.ai.translate; +// +// import lombok.RequiredArgsConstructor; +// import lombok.extern.slf4j.Slf4j; +// import org.springframework.stereotype.Component; +// import org.springframework.web.socket.*; +// import org.springframework.web.socket.handler.BinaryWebSocketHandler; +// +// @Slf4j +// @Component +// @RequiredArgsConstructor +// public class TranslateSocketHandler extends BinaryWebSocketHandler { +// private final OpenAiService openAiService; +// +// @Override +// protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) throws Exception { +// log.info("오디오 청크 수신 ({} bytes)", message.getPayloadLength()); +// +// // 1. 바이너리 데이터 -> MultipartFile 형태로 래핑 +// ByteArrayMultipartFile audioFile = new ByteArrayMultipartFile( +// message.getPayload().array(), +// "audio", +// "chunk.webm", // 실제 클라이언트에서 보내는 포맷(webm/mp3 등)에 맞게! +// "audio/webm" +// ); +// +// // 2. AI 서비스 호출 (STT->번역->TTS) +// byte[] translatedAudio = openAiService.processAudio(audioFile); +// +// // 3. 결과 오디오를 클라이언트로 바이너리 메시지로 전송 +// session.sendMessage(new BinaryMessage(translatedAudio)); +// } +// } diff --git a/src/main/java/com/onebridge/ouch/ai/translate/webSocket/WebSocketConfig.java b/src/main/java/com/onebridge/ouch/ai/translate/webSocket/WebSocketConfig.java new file mode 100644 index 0000000..a93eaf0 --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/webSocket/WebSocketConfig.java @@ -0,0 +1,24 @@ +// package com.onebridge.ouch.ai.translate; +// +// import org.springframework.context.annotation.Configuration; +// import org.springframework.web.socket.config.annotation.EnableWebSocket; +// import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +// import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; +// +// @Configuration +// @EnableWebSocket +// public class WebSocketConfig implements WebSocketConfigurer { +// +// private final TranslateSocketHandler translateSocketHandler; +// +// public WebSocketConfig(TranslateSocketHandler translateHandler) { +// this.translateSocketHandler = translateHandler; +// } +// +// @Override +// public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { +// registry +// .addHandler(translateSocketHandler, "/ws/translate") +// .setAllowedOrigins("*"); // 실제 도메인으로 제한하세요 +// } +// } \ No newline at end of file diff --git a/src/main/java/com/onebridge/ouch/ai/translate/whisper/OpenAiService.java b/src/main/java/com/onebridge/ouch/ai/translate/whisper/OpenAiService.java new file mode 100644 index 0000000..7aaf916 --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/whisper/OpenAiService.java @@ -0,0 +1,162 @@ +// package com.onebridge.ouch.ai.translate.whisper; +// +// import java.io.IOException; +// +// import org.springframework.beans.factory.annotation.Value; +// import org.springframework.stereotype.Service; +// import org.springframework.util.LinkedMultiValueMap; +// import org.springframework.util.MultiValueMap; +// import org.springframework.web.multipart.MultipartFile; +// import org.springframework.http.*; +// import org.springframework.web.client.RestTemplate; +// import org.springframework.core.io.ByteArrayResource; +// +// import com.fasterxml.jackson.databind.JsonNode; +// import com.fasterxml.jackson.databind.ObjectMapper; +// +// @Service +// public class OpenAiService { +// +// @Value("${openai.api-key}") +// private String openAiApiKey; +// +// private final RestTemplate restTemplate = new RestTemplate(); +// +// // (1) 전체 프로세스: 오디오 파일 -> 번역된 음성 오디오 바이너리 반환 +// public byte[] processAudio(MultipartFile audioFile) { +// try { +// // 1. 음성 인식(STT) +// String sttText = speechToText(audioFile); +// +// if (sttText.trim().length() < 2) { +// // 무의미한 결과, 번역/TTS skip +// return new byte[0]; +// } +// +// // 2. 번역 (영어<->한국어 자동 감지) +// String translatedText = translateText(sttText); +// +// // 3. 번역문을 음성으로 (TTS) +// byte[] ttsAudio = textToSpeech(translatedText); +// +// return ttsAudio; +// } catch (Exception e) { +// throw new RuntimeException("OpenAI Service Error", e); +// } +// } +// +// // (2) OpenAI Whisper - STT API 호출 예시 +// private String speechToText(MultipartFile audioFile) { +// String url = "https://api.openai.com/v1/audio/transcriptions"; +// HttpHeaders headers = new HttpHeaders(); +// headers.setBearerAuth(openAiApiKey); +// headers.setContentType(MediaType.MULTIPART_FORM_DATA); +// +// // 파일 파트 구성 +// ByteArrayResource audioResource; +// try { +// audioResource = new ByteArrayResource(audioFile.getBytes()) { +// @Override +// public String getFilename() { +// return audioFile.getOriginalFilename(); +// } +// }; +// } catch (IOException e) { +// throw new RuntimeException("파일을 바이트 배열로 읽는 데 실패했습니다", e); +// } +// +// MultiValueMap body = new LinkedMultiValueMap<>(); +// body.add("file", audioResource); +// body.add("model", "gpt-4o-mini-transcribe"); // OpenAI Whisper +// body.add("response_format", "text"); // 텍스트만 +// +// HttpEntity> requestEntity = new HttpEntity<>(body, headers); +// +// ResponseEntity response = restTemplate.postForEntity(url, requestEntity, String.class); +// if (!response.getStatusCode().is2xxSuccessful()) { +// throw new RuntimeException("STT API 실패: " + response.getBody()); +// } +// return response.getBody().trim(); +// } +// +// private final ObjectMapper objectMapper = new ObjectMapper(); +// +// // (3) GPT-4 등으로 번역 (EN→KO, KO→EN 자동 감지) +// private String translateText(String text) { +// String url = "https://api.openai.com/v1/chat/completions"; +// HttpHeaders headers = new HttpHeaders(); +// headers.setBearerAuth(openAiApiKey); +// headers.setContentType(MediaType.APPLICATION_JSON); +// +// // System 프롬프트: 양방향 자동 번역 +// String systemPrompt = "Detect the language and translate the following text to Korean if it is not Korean, or to English if it is Korean. Output only the translation."; +// +// String reqBody = """ +// { +// "model": "gpt-4o-mini", +// "messages": [ +// {"role": "system", "content": "%s"}, +// {"role": "user", "content": "%s"} +// ], +// "max_tokens": 512 +// } +// """.formatted(systemPrompt, text.replace("\"", "\\\"")); +// +// HttpEntity requestEntity = new HttpEntity<>(reqBody, headers); +// +// ResponseEntity response = restTemplate.postForEntity(url, requestEntity, String.class); +// if (!response.getStatusCode().is2xxSuccessful()) { +// throw new RuntimeException("번역 API 실패: " + response.getBody()); +// } +// +// // 여기서 안전하게 JSON 파싱 +// // 번역 결과 파싱 (아래는 GPT 응답에서 번역 텍스트만 추출) +// String respBody = response.getBody(); +// // gpt-4o 응답 파싱, 실제 응답 json 구조에 따라 아래 코드 수정 필요! gpt-4o API는 아래 경로로 결과가 나옴 +// try { +// JsonNode root = objectMapper.readTree(respBody); +// // gpt-4o API는 아래 경로로 결과가 나옴 +// String translated = root +// .path("choices") +// .get(0) +// .path("message") +// .path("content") +// .asText() +// .trim(); +// return translated; +// } catch (Exception e) { +// throw new RuntimeException("GPT 응답 파싱 실패: " + respBody, e); +// } +// } +// +// // (4) OpenAI TTS (텍스트→음성) +// private byte[] textToSpeech(String text) { +// String url = "https://api.openai.com/v1/audio/speech"; +// HttpHeaders headers = new HttpHeaders(); +// headers.setBearerAuth(openAiApiKey); +// headers.setContentType(MediaType.APPLICATION_JSON); +// +// String reqBody = """ +// { +// "model": "gpt-4o-mini-tts", +// "input": "%s", +// "voice": "nova", +// "response_format": "opus" +// } +// """.formatted(text.replace("\"", "\\\"")); +// +// // 여성 음성, 남성은 alloy, echo, etc. // webm/opus로 반환, mp3 등으로 바꿔도 됨 +// +// HttpEntity requestEntity = new HttpEntity<>(reqBody, headers); +// +// ResponseEntity response = restTemplate.exchange( +// url, HttpMethod.POST, requestEntity, byte[].class +// ); +// +// if (!response.getStatusCode().is2xxSuccessful()) { +// throw new RuntimeException("TTS API 실패: " + response.getBody()); +// } +// +// return response.getBody(); +// } +// } diff --git a/src/main/java/com/onebridge/ouch/ai/translate/whisper/TranslateController.java b/src/main/java/com/onebridge/ouch/ai/translate/whisper/TranslateController.java new file mode 100644 index 0000000..36027bf --- /dev/null +++ b/src/main/java/com/onebridge/ouch/ai/translate/whisper/TranslateController.java @@ -0,0 +1,24 @@ +// package com.onebridge.ouch.ai.translate.whisper; +// +// import lombok.RequiredArgsConstructor; +// import org.springframework.http.MediaType; +// import org.springframework.web.bind.annotation.*; +// import org.springframework.web.multipart.MultipartFile; +// import jakarta.servlet.http.HttpServletResponse; +// +// @RestController +// @RequiredArgsConstructor +// @RequestMapping("/api/translate") +// public class TranslateController { +// private final OpenAiService openAiService; +// +// @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) +// public void translate(@RequestPart MultipartFile audio, HttpServletResponse response) throws Exception { +// // 1. Whisper+GPT+TTS +// byte[] translatedAudio = openAiService.processAudio(audio); +// +// // 2. 결과 전송 (audio/ogg 또는 audio/webm, audio/mp3 등) +// response.setContentType("audio/ogg"); // TTS 반환 포맷에 맞게! +// response.getOutputStream().write(translatedAudio); +// } +// } From 708decb9bc644b33baa56a4e6246afb3c0c7db40 Mon Sep 17 00:00:00 2001 From: 99hyuk Date: Sun, 25 May 2025 00:20:26 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[REFACT]=20=ED=86=B5=EC=97=AD=20AI=20?= =?UTF-8?q?=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../realtime/RealtimeSessionController.java | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/onebridge/ouch/realtime/RealtimeSessionController.java b/src/main/java/com/onebridge/ouch/realtime/RealtimeSessionController.java index dcee785..2ac98c5 100644 --- a/src/main/java/com/onebridge/ouch/realtime/RealtimeSessionController.java +++ b/src/main/java/com/onebridge/ouch/realtime/RealtimeSessionController.java @@ -18,17 +18,19 @@ public ResponseEntity createEphemeralKey() { headers.set("Authorization", "Bearer " + openaiApiKey); headers.set("Content-Type", "application/json"); - String instructions = "You are a strict translation assistant specializing exclusively in medical scenarios for a Korean hospital setting. " - + "- Your SOLE task is strict translation. Under NO circumstances should you answer questions, provide advice, or respond to any input other than translating. " - + "- Korean input represents statements made by the doctor. Translate this into clear, polite, patient-friendly English using simple language, spoken slowly. " - + "- English input represents statements made by the patient. Translate this into clear, polite, respectful Korean using simple expressions, spoken slowly to ensure comprehension. " - + "- DO NOT engage in conversation, respond to direct questions, or acknowledge statements addressed to you. Your ONLY action must be to translate precisely. " - + "- DO NOT rephrase extensively or summarize; translate accurately with gentle phrasing suitable for medical interactions. " - + "- If the input text is already correctly translated, return it exactly as provided without changes. " - + "- The output must ONLY be the translated text, delivered slowly, gently, and in a reassuring manner appropriate for patients. Absolutely NO other communication is permitted."; + String instructions = "한국 병원에서 영어를 사용하는 환자와 한국어를 사용하는 병원 관계자의 대화가 입력될거야. 둘이 언어가 통하지 않으니 영어와 한국어로 통시 통역이 필요해. 그래서 한국 말은 영어로, 영어는 한국어로 동시 통역을 해 줘. 너는 개인적인 대답을 하지 말고 오직 번역만 진행하면 돼. 너한테 말걸어도 문장 그대로 번역만 해. 천천히 친절하게 대답해."; + + // "You are a strict translation assistant specializing exclusively in medical scenarios for a Korean hospital setting. " + // + "- Your SOLE task is strict translation. Under NO circumstances should you answer questions, provide advice, or respond to any input other than translating. " + // + "- Korean input represents statements made by the doctor. Translate this into clear, polite, patient-friendly English using simple language, spoken slowly. " + // + "- English input represents statements made by the patient. Translate this into clear, polite, respectful Korean using simple expressions, spoken slowly to ensure comprehension. " + // + "- DO NOT engage in conversation, respond to direct questions, or acknowledge statements addressed to you. Your ONLY action must be to translate precisely. " + // + "- DO NOT rephrase extensively or summarize; translate accurately with gentle phrasing suitable for medical interactions. " + // + "- If the input text is already correctly translated, return it exactly as provided without changes. " + // + "- The output must ONLY be the translated text, delivered slowly, gently, and in a reassuring manner appropriate for patients. Absolutely NO other communication is permitted."; String requestBody = "{" - + "\"model\": \"gpt-4o-mini-realtime-preview-2024-12-17\"," + + "\"model\": \"gpt-4o-mini-realtime-preview\"," + "\"modalities\": [\"audio\", \"text\"]," + "\"instructions\": \"" + instructions + "\"," + "\"voice\": \"sage\"," @@ -37,9 +39,9 @@ public ResponseEntity createEphemeralKey() { + "\"temperature\": 0.6," + "\"input_audio_transcription\": {" //새로 나온 모델 추가 - + "\"model\": \"whisper-1\"," - + "\"language\": \"en\"," - + "\"prompt\": \"Use accurate medical terminology and explain clearly and simply to the patient.\"" + + "\"model\": \"gpt-4o-mini-transcribe\"," + // + "\"language\": \"\"," + + "\"prompt\": \"This audio input may contain both Korean and English words mixed together. Please transcribe both languages accurately.\"" + "}," + "\"turn_detection\": {" From 43b311450625d6a2f163fbfd00ac555c73b0663c Mon Sep 17 00:00:00 2001 From: 99hyuk Date: Sun, 25 May 2025 01:23:53 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[FEAT]=20=EB=B3=91=EC=9B=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=ED=95=A0=20=EB=95=8C=20=EB=B3=91=EC=9B=90,=20?= =?UTF-8?q?=EC=95=BD=EA=B5=AD=EB=94=B0=EB=9D=BC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=A0=20=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80,=20=EC=9A=94=EC=96=91=EB=B3=91=EC=9B=90=EC=9D=80=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hospital/HospitalController.java | 9 ++-- .../response/HospitalDistanceResponse.java | 4 ++ .../hospital/HospitalRepository.java | 32 ++++++++++---- .../hospital/HospitalSearchService.java | 42 ++++++++++++++----- 4 files changed, 65 insertions(+), 22 deletions(-) diff --git a/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java b/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java index c22ea80..dd727bc 100644 --- a/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java +++ b/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java @@ -24,17 +24,20 @@ public class HospitalController { private final HospitalDetailService hospitalDetailService; private final DepartmentService departmentService; - @Operation(summary = "거리 순 병원 조회 API", description = "입력된 진료과, 위도(lat), 경도(lng)를 기준으로 병원 목록을 거리 순으로 조회합니다. " - + "진료과를 입력하지 않으면 입력된 위도, 경도를 기준으로 모든 병원 목록을 거리 순으로 조회합니다. 위도, 경도는 필수로 입력해야 합니다. ") + @Operation(summary = "거리 순 병원 조회 API", description = "입력된 진료과(department), 종별코드명(type) 위도(lat), 경도(lng)를 기준으로 병원 목록을 거리 순으로 조회합니다. " + + "진료과나 종별코드명를 입력하지 않으면 입력된 위도, 경도를 기준으로 모든 병원 목록을 거리 순으로 조회합니다. 위도, 경도는 필수로 입력해야 합니다. " + + "진료과나 종별코드명은 선택적으로 입력 가능합니다. 종별코드명은 병원(약국 제외 모든 병원), 약국 두 가지로 분류됩니다." + + "진료과가 존재하지 않는 경우가 있는데 이는 해당 병원이 진료과를 제공하지 않기 때문이며, 응답 필드 중에 department 뿐만 아니라 type(치과의원, 종합병원 등)을 진료과처럼 사용하면 웬만하면 알아볼 수 있습니다.") @GetMapping("/search") public List searchHospitals( @RequestParam(required = false) String department, + @RequestParam(required = false) String type, // 종별코드명 @RequestParam Double lat, @RequestParam Double lng, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size ) { - return hospitalSearchService.searchHospitals(department, lat, lng, page, size); + return hospitalSearchService.searchHospitals(department, type, lat, lng, page, size); } @Operation(summary = "병원 상세 조회 API", description = "입력된 병원 고유ID를 통해 병원 상세 정보를 조회합니다.") diff --git a/src/main/java/com/onebridge/ouch/dto/hospital/response/HospitalDistanceResponse.java b/src/main/java/com/onebridge/ouch/dto/hospital/response/HospitalDistanceResponse.java index bcd3f31..f40218b 100644 --- a/src/main/java/com/onebridge/ouch/dto/hospital/response/HospitalDistanceResponse.java +++ b/src/main/java/com/onebridge/ouch/dto/hospital/response/HospitalDistanceResponse.java @@ -1,5 +1,7 @@ package com.onebridge.ouch.dto.hospital.response; +import java.util.List; + import lombok.Getter; import lombok.Setter; @@ -13,4 +15,6 @@ public class HospitalDistanceResponse { private Double lat; private Double lng; private Double distance; //km + private String type; // 종별코드명 (ex. '약국', '병원', '의원' 등) + private List departments; // 병원이 보유한 진료과 리스트 } diff --git a/src/main/java/com/onebridge/ouch/repository/hospital/HospitalRepository.java b/src/main/java/com/onebridge/ouch/repository/hospital/HospitalRepository.java index a6fec35..9caca43 100644 --- a/src/main/java/com/onebridge/ouch/repository/hospital/HospitalRepository.java +++ b/src/main/java/com/onebridge/ouch/repository/hospital/HospitalRepository.java @@ -14,10 +14,17 @@ public interface HospitalRepository extends JpaRepository { // 전체 병원 거리순 정렬 (진료과 미입력시) @Query(value = "SELECT h.ykiho, h.name, h.address, h.tel, h.lat, h.lng, " + - " (6371 * acos(cos(radians(:lat)) * cos(radians(h.lat)) " + - " * cos(radians(h.lng) - radians(:lng)) + sin(radians(:lat)) * sin(radians(h.lat)))) as distance " + + "h.type, " + // 종별코드명 추가 + "GROUP_CONCAT(DISTINCT hd.department_name) as departments, " + // 진료과 집계 + "(6371 * acos(cos(radians(:lat)) * cos(radians(h.lat)) " + + "* cos(radians(h.lng) - radians(:lng)) + sin(radians(:lat)) * sin(radians(h.lat)))) as distance " + "FROM hospital h " + + "LEFT JOIN hospital_department hd ON h.ykiho = hd.ykiho " + + // 아래 WHERE 절은 동적으로 구성됨 (여기서는 전부 주석/placeholder) + // "WHERE (조건) " "WHERE h.lat IS NOT NULL AND h.lng IS NOT NULL " + + "AND h.type != '요양병원' " + + "GROUP BY h.ykiho " + "ORDER BY distance ASC " + "LIMIT :limit OFFSET :offset", nativeQuery = true) @@ -31,18 +38,27 @@ List findAllOrderByDistance( // 진료과 기반 병원 거리순 정렬 (병원-진료과 조인) @Query(value = "SELECT h.ykiho, h.name, h.address, h.tel, h.lat, h.lng, " + - " (6371 * acos(cos(radians(:lat)) * cos(radians(h.lat)) " + - " * cos(radians(h.lng) - radians(:lng)) + sin(radians(:lat)) * sin(radians(h.lat)))) as distance " + + "h.type, " + + "GROUP_CONCAT(DISTINCT hd.department_name) as departments, " + + "(6371 * acos(cos(radians(:lat)) * cos(radians(h.lat)) " + + "* cos(radians(h.lng) - radians(:lng)) + sin(radians(:lat)) * sin(radians(h.lat)))) as distance " + "FROM hospital h " + - "JOIN hospital_department hd ON h.ykiho = hd.ykiho " + - "WHERE hd.department_name = :departmentName AND h.lat IS NOT NULL AND h.lng IS NOT NULL " + + "LEFT JOIN hospital_department hd ON h.ykiho = hd.ykiho " + + "WHERE (:type IS NULL OR h.type = :type " + + " OR (:type = '병원' AND h.type != '약국')) " + // 병원이면 약국 제외 + "AND h.type != '요양병원' " + + "AND (:department1 IS NULL OR hd.department_name IN (:department1, :department2)) " + // 내과+가정의학과 포함 + "AND h.lat IS NOT NULL AND h.lng IS NOT NULL " + + "GROUP BY h.ykiho " + "ORDER BY distance ASC " + "LIMIT :limit OFFSET :offset", nativeQuery = true) - List findByDepartmentOrderByDistance( - @Param("departmentName") String departmentName, + List findWithConditionsOrderByDistance( @Param("lat") double lat, @Param("lng") double lng, + @Param("type") String type, + @Param("department1") String department1, + @Param("department2") String department2, @Param("limit") int limit, @Param("offset") int offset ); diff --git a/src/main/java/com/onebridge/ouch/service/hospital/HospitalSearchService.java b/src/main/java/com/onebridge/ouch/service/hospital/HospitalSearchService.java index facbb98..d84f570 100644 --- a/src/main/java/com/onebridge/ouch/service/hospital/HospitalSearchService.java +++ b/src/main/java/com/onebridge/ouch/service/hospital/HospitalSearchService.java @@ -2,33 +2,51 @@ import org.springframework.stereotype.Service; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import com.onebridge.ouch.dto.hospital.response.HospitalDistanceResponse; import com.onebridge.ouch.repository.hospital.HospitalRepository; +import lombok.RequiredArgsConstructor; + @Service +@RequiredArgsConstructor public class HospitalSearchService { private final HospitalRepository hospitalRepository; - public HospitalSearchService(HospitalRepository hospitalRepository) { - this.hospitalRepository = hospitalRepository; - } - public List searchHospitals( String department, + String type, // 종별코드명 ex. '약국', '병원' Double lat, Double lng, int page, int size ) { - if (lat == null || lng == null) { - throw new IllegalArgumentException("좌표(lat, lng)는 필수입니다."); - } + if (lat == null || lng == null) throw new IllegalArgumentException("좌표(lat, lng)는 필수입니다."); + int offset = page * size; List rawList; + + // 진료과: 내과→내과,가정의학과 모두 포함 + String department1 = null; + String department2 = null; if (department != null && !department.isBlank()) { - rawList = hospitalRepository.findByDepartmentOrderByDistance(department, lat, lng, size, offset); + if (department.equals("내과") || department.equals("가정의학과")) { + department1 = "내과"; + department2 = "가정의학과"; + } else { + department1 = department; + department2 = department; + } + } + + // type(종별코드명)도 null/입력값에 따라 처리 + if ((department1 != null && department2 != null) || (type != null && !type.isBlank())) { + rawList = hospitalRepository.findWithConditionsOrderByDistance( + lat, lng, + type, department1, department2, size, offset + ); } else { rawList = hospitalRepository.findAllOrderByDistance(lat, lng, size, offset); } @@ -36,16 +54,18 @@ public List searchHospitals( List result = new ArrayList<>(); for (Object[] row : rawList) { HospitalDistanceResponse dto = new HospitalDistanceResponse(); - // row 순서는 hospital 테이블 컬럼 순서 + 마지막에 distance dto.setYkiho((String) row[0]); dto.setName((String) row[1]); dto.setAddress((String) row[2]); dto.setTel((String) row[3]); dto.setLat(row[4] != null ? ((Number)row[4]).doubleValue() : null); dto.setLng(row[5] != null ? ((Number)row[5]).doubleValue() : null); - dto.setDistance(row[6] != null ? ((Number)row[6]).doubleValue() : null); + dto.setType((String) row[6]); + // 진료과 집계값은 ,로 구분된 String → List로 변환 + dto.setDepartments(row[7] != null ? Arrays.asList(((String) row[7]).split(",")) : null); + dto.setDistance(row[8] != null ? ((Number)row[8]).doubleValue() : null); result.add(dto); } return result; } -} +} \ No newline at end of file From 58ee4db0f1515ba1e2cb17dd150b1261e5aba33d Mon Sep 17 00:00:00 2001 From: 99hyuk Date: Sun, 25 May 2025 01:45:59 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[FIX]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=EC=84=9C=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=ED=95=A0=20=EB=95=8C=20?= =?UTF-8?q?=EC=96=B8=EC=96=B4=EB=8A=94=20=EB=B0=9B=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/onebridge/ouch/converter/MypageConverter.java | 4 +--- .../ouch/dto/mypage/request/MypageProfileUpdateRequest.java | 6 +++--- .../com/onebridge/ouch/service/mypage/MypageService.java | 6 +++--- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/onebridge/ouch/converter/MypageConverter.java b/src/main/java/com/onebridge/ouch/converter/MypageConverter.java index ead3482..b38e679 100644 --- a/src/main/java/com/onebridge/ouch/converter/MypageConverter.java +++ b/src/main/java/com/onebridge/ouch/converter/MypageConverter.java @@ -16,15 +16,13 @@ public MypageGetProfileResponse userToMypageGetProfileResponse(User user) { user.getPhoneNumber(), user.getEmail(), user.getLanguage().getName()); } - public User updateUserByUpdateProfileRequest(User user, MypageProfileUpdateRequest request, Nation nation, - Language language) { + public User updateUserByUpdateProfileRequest(User user, MypageProfileUpdateRequest request, Nation nation) { return user.toBuilder() .nickname(request.getNickname()) .phoneNumber(request.getPhoneNumber()) .gender(request.getGender()) .email(request.getEmail()) .nation(nation) - .language(language) .build(); } } diff --git a/src/main/java/com/onebridge/ouch/dto/mypage/request/MypageProfileUpdateRequest.java b/src/main/java/com/onebridge/ouch/dto/mypage/request/MypageProfileUpdateRequest.java index 82622f0..8bf4052 100644 --- a/src/main/java/com/onebridge/ouch/dto/mypage/request/MypageProfileUpdateRequest.java +++ b/src/main/java/com/onebridge/ouch/dto/mypage/request/MypageProfileUpdateRequest.java @@ -28,7 +28,7 @@ public class MypageProfileUpdateRequest { @NotBlank(message = "Email is mandatory.") private String email; - @NotNull(message = "Language is mandatory.") - @Schema(example = "kr") - private String languageCode; + // @NotNull(message = "Language is mandatory.") + // @Schema(example = "kr") + // private String languageCode; } diff --git a/src/main/java/com/onebridge/ouch/service/mypage/MypageService.java b/src/main/java/com/onebridge/ouch/service/mypage/MypageService.java index c20e2ed..b1b46e6 100644 --- a/src/main/java/com/onebridge/ouch/service/mypage/MypageService.java +++ b/src/main/java/com/onebridge/ouch/service/mypage/MypageService.java @@ -41,10 +41,10 @@ public void mypageUpdateProfile(Long userId, MypageProfileUpdateRequest request) Nation nation = nationRepository.findByCode(request.getNationCode()) .orElseThrow(() -> new OuchException(CommonErrorCode.NATION_NOT_FOUND)); - Language language = languageRepository.findByCode(request.getLanguageCode()) - .orElseThrow(() -> new OuchException(CommonErrorCode.LANGUAGE_NOT_FOUND)); + // Language language = languageRepository.findByCode(request.getLanguageCode()) + // .orElseThrow(() -> new OuchException(CommonErrorCode.LANGUAGE_NOT_FOUND)); - User updatedUser = mypageConverter.updateUserByUpdateProfileRequest(user, request, nation, language); + User updatedUser = mypageConverter.updateUserByUpdateProfileRequest(user, request, nation); userRepository.save(updatedUser); } From 6b14ba5504ef8a9dd54e6fa6ad7f0144b4c466e6 Mon Sep 17 00:00:00 2001 From: 99hyuk Date: Sun, 25 May 2025 01:50:53 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[REFACT]=20=EB=B3=91=EC=9B=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=ED=95=A0=20=EB=95=8C=20=EA=B2=80=EC=83=89=20=ED=95=84?= =?UTF-8?q?=ED=84=B0=EB=8A=94=20=ED=95=9C=EA=B8=80=EB=A1=9C=EB=A7=8C=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EB=8B=A4=EB=8A=94=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=EC=9D=84=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=EC=97=90=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../onebridge/ouch/controller/hospital/HospitalController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java b/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java index dd727bc..8ef46a2 100644 --- a/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java +++ b/src/main/java/com/onebridge/ouch/controller/hospital/HospitalController.java @@ -26,7 +26,7 @@ public class HospitalController { @Operation(summary = "거리 순 병원 조회 API", description = "입력된 진료과(department), 종별코드명(type) 위도(lat), 경도(lng)를 기준으로 병원 목록을 거리 순으로 조회합니다. " + "진료과나 종별코드명를 입력하지 않으면 입력된 위도, 경도를 기준으로 모든 병원 목록을 거리 순으로 조회합니다. 위도, 경도는 필수로 입력해야 합니다. " - + "진료과나 종별코드명은 선택적으로 입력 가능합니다. 종별코드명은 병원(약국 제외 모든 병원), 약국 두 가지로 분류됩니다." + + "진료과나 종별코드명은 선택적으로 입력 가능합니다. 진료과는 한글로만 입력이 가능하며, 종별코드명은 병원(약국 제외 모든 병원), 약국 두 가지의 키워드로만 입력이 됩니다." + "진료과가 존재하지 않는 경우가 있는데 이는 해당 병원이 진료과를 제공하지 않기 때문이며, 응답 필드 중에 department 뿐만 아니라 type(치과의원, 종합병원 등)을 진료과처럼 사용하면 웬만하면 알아볼 수 있습니다.") @GetMapping("/search") public List searchHospitals(