diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6396a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM gradle:jdk17-jammy AS build +COPY --chown=gradle:gradle . /app +WORKDIR /app +RUN chmod +x gradlew +RUN ./gradlew build --no-daemon -x test # 테스트 제외로 빌드 속도 향상 + +# ... (빌드 단계 생략: gradle:jdk17-jammy AS build 등 기존 코드 유지) + +FROM eclipse-temurin:17-jdk-jammy +WORKDIR /app + +# Cloud SQL Auth Proxy 설치 및 권한 설정 +ARG CLOUD_SQL_PROXY_SHA256="8c6d76380f4b7005473eb2e13991d6239f90da021cf58d91d062739479e577cf" +RUN wget -q https://storage.googleapis.com/cloud-sql-connectors/cloud-sql-proxy/v2.8.1/cloud-sql-proxy.linux.amd64 -O /usr/local/bin/cloud_sql_proxy && \ + echo "${CLOUD_SQL_PROXY_SHA256} /usr/local/bin/cloud_sql_proxy" | sha256sum -c - && \ + chmod +x /usr/local/bin/cloud_sql_proxy + +COPY --from=build /app/build/libs/*.jar app.jar +COPY ./render-access.json /secrets/render-access.json +RUN chmod 400 /secrets/render-access.json + +ENTRYPOINT ["sh", "-c", "./cloud_sql_proxy --address 127.0.0.1 --port 3306 --credentials-file /secrets/render-access.json folkloric-clock-391008:asia-northeast3:ureca-3-unity & sleep 30; java -jar app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle index abfbee5..539e2f4 100644 --- a/build.gradle +++ b/build.gradle @@ -8,6 +8,10 @@ group = 'com.ureca' version = '0.0.1-SNAPSHOT' description = 'unity' +bootJar { + mainClass = 'com.ureca.unity.UnityApplication' +} + java { toolchain { languageVersion = JavaLanguageVersion.of(17) @@ -29,6 +33,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3' + implementation 'com.google.cloud:google-cloud-storage:2.30.0' implementation 'com.google.cloud:google-cloud-speech:4.46.0' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' @@ -36,9 +41,6 @@ dependencies { implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0' - testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:4.0.1' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - implementation 'io.jsonwebtoken:jjwt-api:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' diff --git a/src/main/java/com/ureca/unity/domain/call/service/RecordingServiceImpl.java b/src/main/java/com/ureca/unity/domain/call/service/RecordingServiceImpl.java index e95cf73..fa2cdc0 100644 --- a/src/main/java/com/ureca/unity/domain/call/service/RecordingServiceImpl.java +++ b/src/main/java/com/ureca/unity/domain/call/service/RecordingServiceImpl.java @@ -1,7 +1,10 @@ package com.ureca.unity.domain.call.service; import com.ureca.unity.domain.call.util.Converter; +import com.ureca.unity.domain.stt.mapper.CounselingResultMapper; +import com.ureca.unity.domain.stt.model.CounselingResult; import com.ureca.unity.domain.stt.service.SttService; +import com.ureca.unity.domain.summary.mapper.SummaryMapper; import com.ureca.unity.global.exception.CustomException; import com.ureca.unity.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -34,6 +37,8 @@ public class RecordingServiceImpl implements RecordingService { private final WebClient.Builder webClientBuilder; private final SttService sttService; + private final CounselingResultMapper sttMapper; + private final SummaryMapper summaryMapper; //@Value 주입이 완료된 후, 호출 시점에 WebClient 빌드 private WebClient getWebClient() { @@ -126,6 +131,22 @@ public String start(String resourceId, String channelName, String uid, String to public void stop(String resourceId, String sid, String channelName, String uid, String userId) { log.info("[Agora] Stop 요청 - sid: {}", sid); + Long longUserId=Long.parseLong(userId); + CounselingResult job = CounselingResult.builder() + .userId(longUserId) + .counselorId(1L) + .counselingType("CALL") + .status("LOADING") + .build(); + boolean jobPersisted=false; + try{ + sttMapper.insert(job); + summaryMapper.insertSummary(job.getCounselingResultId(), longUserId); + jobPersisted=true; + }catch (Exception e){ + log.error("결과 저장할 DB 연결 실패, 녹음 종료 진행.",e); + } + Map body = Map.of( "cname", channelName, "uid", uid, @@ -140,6 +161,9 @@ public void stop(String resourceId, String sid, String channelName, String uid, .bodyToMono(Void.class) .block(Duration.ofSeconds(10)); log.info("[Agora] Stop 성공"); + if (!jobPersisted) { + throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); + } //s3의 파일->wav로 변경->stt CompletableFuture.runAsync(() -> { @@ -152,7 +176,7 @@ public void stop(String resourceId, String sid, String channelName, String uid, if (wavFile != null && wavFile.exists()) { log.info("최종 WAV 생성 성공: {}", wavFile.getAbsolutePath()); - sttService.startStt(wavFile, Long.parseLong(userId)); + sttService.startStt(wavFile, longUserId, job); } } catch (Exception e) { log.error("비동기 변환 작업 중 오류: {}", e); @@ -161,6 +185,12 @@ public void stop(String resourceId, String sid, String channelName, String uid, } catch (Exception e) { log.error("[Agora] Stop 실패: {}", e.getMessage()); + if(jobPersisted){ + job.setStatus("FAIL"); + sttMapper.updateResult(job); + Long summaryId = summaryMapper.findLatestSummaryId(longUserId, job.getCounselingResultId()); + if(summaryId!=null)summaryMapper.updateStatus(summaryId, "FAIL"); + } throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR); } } diff --git a/src/main/java/com/ureca/unity/domain/call/util/GcsUploader.java b/src/main/java/com/ureca/unity/domain/call/util/GcsUploader.java new file mode 100644 index 0000000..6407749 --- /dev/null +++ b/src/main/java/com/ureca/unity/domain/call/util/GcsUploader.java @@ -0,0 +1,48 @@ +package com.ureca.unity.domain.call.util; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.storage.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.Path; + +@Slf4j +@Component +public class GcsUploader { + + @Value("${google.cloud.project-id}") private String projectId; + @Value("${google.cloud.bucket-name}") private String bucketName; + @Value("${google.cloud.credentials.location}") private String keyPath; + + public String uploadWav( + String objectName, + Path wavPath + ) throws Exception { + Resource resource = new DefaultResourceLoader().getResource(keyPath); + GoogleCredentials credentials = + GoogleCredentials.fromStream(resource.getInputStream()); + + Storage storage = StorageOptions.newBuilder() + .setProjectId(projectId) + .setCredentials(credentials) + .build() + .getService(); + + log.error("DEBUG GCS PARAMS >>> bucketName=[{}], objectName=[{}], wavPath=[{}]", + bucketName, objectName, wavPath); + + BlobId blobId = BlobId.of(bucketName, objectName); + BlobInfo blobInfo = BlobInfo.newBuilder(blobId) + .setContentType("audio/wav") + .build(); + + storage.create(blobInfo, Files.readAllBytes(wavPath)); + + return "gs://" + bucketName + "/" + objectName; + } +} diff --git a/src/main/java/com/ureca/unity/domain/stt/service/SttService.java b/src/main/java/com/ureca/unity/domain/stt/service/SttService.java index c820850..9c52418 100644 --- a/src/main/java/com/ureca/unity/domain/stt/service/SttService.java +++ b/src/main/java/com/ureca/unity/domain/stt/service/SttService.java @@ -6,7 +6,7 @@ public interface SttService { - CounselingResult startStt(File filePath, long userId); + CounselingResult startStt(File filePath, long userId, CounselingResult job); CounselingResult getStt(Long counselingId); } diff --git a/src/main/java/com/ureca/unity/domain/stt/service/SttServiceImpl.java b/src/main/java/com/ureca/unity/domain/stt/service/SttServiceImpl.java index 12d81ca..f64731f 100644 --- a/src/main/java/com/ureca/unity/domain/stt/service/SttServiceImpl.java +++ b/src/main/java/com/ureca/unity/domain/stt/service/SttServiceImpl.java @@ -4,10 +4,10 @@ import com.google.api.gax.longrunning.OperationFuture; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.speech.v1.*; -import com.google.protobuf.ByteString; import com.ureca.unity.domain.stt.mapper.CounselingResultMapper; import com.ureca.unity.domain.stt.model.CounselingResult; import com.ureca.unity.domain.summary.service.SummaryService; +import com.ureca.unity.domain.call.util.GcsUploader; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -17,7 +17,7 @@ import java.io.File; import java.io.InputStream; -import java.nio.file.Files; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Service @@ -27,71 +27,85 @@ public class SttServiceImpl implements SttService { private final CounselingResultMapper sttMapper; private final SummaryService summaryService; + private final GcsUploader gcpUploader; @Value("${google.cloud.credentials.location}") private String keyPath; @Override - public CounselingResult startStt(File file, long userId) { - // 초기 작업 저장 - CounselingResult job = CounselingResult.builder() - .userId(userId) - .counselorId(1L) - .counselingType("CALL") - .status("LOADING") - .build(); - sttMapper.insert(job); + public CounselingResult startStt(File file, long userId, CounselingResult job) { + String gcsUri = null; try { - // 1. 오디오 파일 유효성 검사 및 읽기 - byte[] data = Files.readAllBytes(file.toPath()); - if (data.length == 0) throw new RuntimeException("파일이 비어있습니다."); + if (!file.exists() || file.length() == 0) { + throw new RuntimeException("오디오 파일이 존재하지 않거나 비어있습니다."); + } + + log.info("STT 시작 (Long Audio) - file: {}, size: {} bytes", + file.getAbsolutePath(), file.length()); + + // 1️⃣ GCS 업로드 + String objectName = "recordings/" + + userId + "/" + + job.getCounselingResultId() + ".wav"; - log.info("STT 시작 - 파일 크기: {} bytes", data.length); - ByteString audioBytes = ByteString.copyFrom(data); + gcsUri = gcpUploader.uploadWav( + objectName, + file.toPath() + ); + log.info("GCS 업로드 완료: {}", gcsUri); + + // 2️⃣ RecognitionAudio (URI 기반) RecognitionAudio audio = RecognitionAudio.newBuilder() - .setContent(audioBytes) + .setUri(gcsUri) .build(); - // 2. 설정 최적화 (가장 범용적인 설정) + // 3️⃣ Long Audio 최적화 설정 RecognitionConfig config = RecognitionConfig.newBuilder() - // 브라우저 녹음 파일(WebM/Wav) 헤더를 자동 감지하도록 설정 - .setEncoding(RecognitionConfig.AudioEncoding.ENCODING_UNSPECIFIED) .setLanguageCode("ko-KR") - .setSampleRateHertz(16000) // 특정 포맷이 아니면 생략하는 것이 안전 - .setAudioChannelCount(1) // 아고라 녹음은 보통 단일 채널 + .setEncoding(RecognitionConfig.AudioEncoding.ENCODING_UNSPECIFIED) +// .setSampleRateHertz(16000) // wav 실제 값과 반드시 일치 + .setAudioChannelCount(1) + .setEnableAutomaticPunctuation(true) + .setUseEnhanced(true) + .setModel("latest_long") .build(); - // 3. Google 인증 로드 + // 4️⃣ Google 인증 Resource resource = new DefaultResourceLoader().getResource(keyPath); try (InputStream is = resource.getInputStream()) { + GoogleCredentials credentials = GoogleCredentials.fromStream(is); SpeechSettings settings = SpeechSettings.newBuilder() - .setCredentialsProvider(FixedCredentialsProvider.create(credentials)) + .setCredentialsProvider( + FixedCredentialsProvider.create(credentials)) .build(); try (SpeechClient speechClient = SpeechClient.create(settings)) { - log.info("Google STT 비동기 요청 전송 중..."); - // 4. 비동기 요청 실행 (긴 파일 대응) - OperationFuture responseFuture = + log.info("Google STT longRunningRecognize 요청 전송"); + + OperationFuture< + LongRunningRecognizeResponse, + LongRunningRecognizeMetadata + > future = speechClient.longRunningRecognizeAsync(config, audio); - // 5. 완료될 때까지 기다림 (여기서 블로킹되어야 로그가 남고 파일 삭제가 안전함) - LongRunningRecognizeResponse response = responseFuture.get(); + // ⏳ 긴 파일 대기 (최대 30분) + LongRunningRecognizeResponse response = + future.get(30, TimeUnit.MINUTES); - // 결과 추출 및 로그 String text = response.getResultsList().stream() .map(r -> r.getAlternatives(0).getTranscript()) .collect(Collectors.joining(" ")); - if (text.trim().isEmpty()) { - log.warn("STT 결과가 비어있습니다. 오디오 데이터 확인 필요."); + if (text.isBlank()) { + log.warn("STT 결과가 비어있음"); job.setStatus("FAIL"); job.setTexts("No speech detected."); } else { - log.info("STT 변환 완료: {}", text); + log.info("STT 완료 (length={} chars)", text.length()); job.setStatus("SUCCESS"); job.setTexts(text); } @@ -99,28 +113,34 @@ public CounselingResult startStt(File file, long userId) { } } catch (Exception e) { - log.error("STT 처리 중 치명적 오류: ", e); + log.error("STT 처리 중 오류 발생", e); job.setStatus("FAIL"); job.setTexts("Error: " + e.getMessage()); + } finally { - // 모든 작업이 끝난 후 파일 삭제 + // 5️⃣ 로컬 파일 삭제 if (file.exists()) { - boolean isDeleted = file.delete(); - log.info("임시 파일 삭제 여부: {}", isDeleted); + boolean deleted = file.delete(); + log.info("로컬 wav 파일 삭제: {}", deleted); } } - // 6. DB 업데이트 및 후속 작업 + // 6️⃣ DB 업데이트 sttMapper.updateResult(job); - // 성공 시에만 요약 서비스 호출 + // 7️⃣ 성공 시 요약 호출 if ("SUCCESS".equals(job.getStatus())) { - summaryService.createSummary(job.getCounselingResultId(),job.getUserId(),job.getTexts()); + summaryService.createSummary( + job.getCounselingResultId(), + job.getUserId(), + job.getTexts() + ); } return job; } + @Override public CounselingResult getStt(Long counselingResultId) { return sttMapper.findByCounselingId(counselingResultId); diff --git a/src/main/java/com/ureca/unity/domain/summary/mapper/SummaryMapper.java b/src/main/java/com/ureca/unity/domain/summary/mapper/SummaryMapper.java index 8352e5d..008cc71 100644 --- a/src/main/java/com/ureca/unity/domain/summary/mapper/SummaryMapper.java +++ b/src/main/java/com/ureca/unity/domain/summary/mapper/SummaryMapper.java @@ -34,7 +34,7 @@ void updateStatus( Boolean findBookmarkStatus(@Param("summaryId") Long summaryId); - void updateBookmark( + int updateBookmark( @Param("summaryId") Long summaryId, @Param("isBookmarked") boolean isBookmarked ); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1609991..a229daf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,6 +9,7 @@ spring: password: ${DB_PASSWORD} hikari: + pool-name: hikari-pool maximum-pool-size: 10 minimum-idle: 5 connection-timeout: 5000 @@ -22,7 +23,7 @@ mybatis: type-aliases-package: com.ureca.unity.domain server: - port: 8080 + port: ${PORT:8080} logging: level: @@ -44,6 +45,57 @@ cors: cookie: secure: ${COOKIE_SECURE:true} +oauth: + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_SECRET} + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + redirect-uri: ${NAVER_REDIRECT_URI} + + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + token-uri: https://oauth2.googleapis.com/token + user-info-uri: https://www.googleapis.com/oauth2/v2/userinfo + redirect-uri: ${GOOGLE_REDIRECT_URI} + + kakao: + client-id: ${KAKAO_CLIENT_ID} + # client-secret: "" + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + redirect-uri: ${KAKAO_REDIRECT_URI} + admin-key: ${KAKAO_ADMIN_KEY} + +jwt: + issuer: unity + secret: ${JWT_SECRET} + access-expiration-seconds: 3600 # 1시간 + refresh-expiration-seconds: 604800 # 7일 + +agora: + appId: ${AGORA_APP_ID} + appCert: ${AGORA_APP_CERT} + customerId: ${AGORA_CUSTOMER_ID} + customerSecret: ${AGORA_CUSTOMER_SECRET} + +aws: + s3: + bucket: ${S3_BUCKET} + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: ${S3_REGION} + +google: + cloud: + credentials: + location: ${GOOGLE_CLOUD_LOCATION} + project-id: ${GOOGLE_CLOUD_PROJECT_ID} + bucket-name: ${GOOGLE_CLOUD_BUCKET_NAME} + +gemini: + api-key: ${GEMINI_API_KEY} security: oauth-token: diff --git a/src/test/java/com/ureca/unity/UnityApplicationTests.java b/src/test/java/com/ureca/unity/UnityApplicationTests.java deleted file mode 100644 index 2f891c8..0000000 --- a/src/test/java/com/ureca/unity/UnityApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ureca.unity; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class UnityApplicationTests { - - @Test - void contextLoads() { - } - -}