Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
55d15a2
feat: google cloud storage 연결
40food Jan 27, 2026
b64ff5c
Merge branch 'develop' into URECA-65/Feat/longerstt
40food Jan 27, 2026
298003c
fix: 녹음 불가 문제 수정 및 긴 STT 구현
40food Jan 27, 2026
af33842
fix: 코드 리뷰 반영
40food Jan 28, 2026
cdc3640
feat: 배포 준비
40food Jan 28, 2026
719caf2
fix: 빌드 문제 수정 및 리뷰 반영
40food Jan 28, 2026
bd1c5b3
Merge branch 'develop' into URECA-65/Feat/longerstt
40food Jan 28, 2026
953949a
fix: mainClass 경로 설정
40food Jan 28, 2026
4389db6
fix: 배포 jar 설정 수정
40food Jan 28, 2026
ce98dd0
fix: jar 경로 수정
40food Jan 28, 2026
88bb24f
fix: jar 경로 재설정
40food Jan 28, 2026
7415665
fix: Docker file 수정
40food Jan 28, 2026
7ef8d3a
Revert "fix: Docker file 수정"
40food Jan 28, 2026
95d2de0
fix: 도커 설정 수정...
40food Jan 28, 2026
f058689
fix: dockerfile 제발 마지막 수정이길
40food Jan 28, 2026
6182572
fix: 진짜 찐막
40food Jan 28, 2026
1ec5008
fix: gradlew 실행권한 부여
40food Jan 28, 2026
f4e305e
fix: render를 google sql에 연결
40food Jan 28, 2026
449b2d9
fix: render를 google sql에 연결2
40food Jan 28, 2026
02e4ac7
fix: render를 google sql에 연결3
40food Jan 28, 2026
eba588e
fix: render를 google sql에 연결4
40food Jan 28, 2026
b6581e2
fix: address 좁힘
40food Jan 28, 2026
836bc3e
fix: proxy가 자리잡을 시간 추가
40food Jan 28, 2026
6c20201
fix: 로깅 레벨 높임
40food Jan 28, 2026
32cbcc0
fix: render를 google sql에 연결5
40food Jan 28, 2026
b8b8893
fix: 시간 다시 늘림
40food Jan 28, 2026
31ad600
fix: 리뷰 반영
40food Jan 28, 2026
ab65cdf
Merge branch 'develop' into URECA-65/Feat/longerstt
40food Jan 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
8 changes: 5 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -29,16 +33,14 @@ 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'
annotationProcessor 'org.projectlombok:lombok'

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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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<String, Object> body = Map.of(
"cname", channelName,
"uid", uid,
Expand All @@ -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(() -> {
Expand All @@ -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);
Expand All @@ -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);
}
}
Expand Down
48 changes: 48 additions & 0 deletions src/main/java/com/ureca/unity/domain/call/util/GcsUploader.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
100 changes: 60 additions & 40 deletions src/main/java/com/ureca/unity/domain/stt/service/SttServiceImpl.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -27,100 +27,120 @@ 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<LongRunningRecognizeResponse, LongRunningRecognizeMetadata> 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);
}
}
}

} 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ void updateStatus(

Boolean findBookmarkStatus(@Param("summaryId") Long summaryId);

void updateBookmark(
int updateBookmark(
@Param("summaryId") Long summaryId,
@Param("isBookmarked") boolean isBookmarked
);
Expand Down
Loading