백엔드 stt 서비스 개발

This commit is contained in:
cyjadela 2025-10-23 15:35:47 +09:00
parent 8c148e2721
commit 53f499cc7c
43 changed files with 4726 additions and 0 deletions

View File

@ -0,0 +1,112 @@
spring:
application:
name: stt
# Database Configuration
datasource:
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:4.230.65.89}:${DB_PORT:5432}/${DB_NAME:sttdb}
username: ${DB_USERNAME:hgzerouser}
password: ${DB_PASSWORD:}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
leak-detection-threshold: 60000
# JPA Configuration
jpa:
show-sql: ${SHOW_SQL:true}
properties:
hibernate:
format_sql: true
use_sql_comments: true
hibernate:
ddl-auto: ${DDL_AUTO:update}
# Redis Configuration
data:
redis:
host: ${REDIS_HOST:20.249.177.114}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
max-wait: -1ms
database: ${REDIS_DATABASE:2}
# Server Configuration
server:
port: ${SERVER_PORT:8082}
# JWT Configuration
jwt:
secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800}
# CORS Configuration
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
# Azure Speech Service Configuration
azure:
speech:
subscription-key: ${AZURE_SPEECH_SUBSCRIPTION_KEY:}
region: ${AZURE_SPEECH_REGION:eastus}
language: ${AZURE_SPEECH_LANGUAGE:ko-KR}
blob:
connection-string: ${AZURE_BLOB_CONNECTION_STRING:}
container-name: ${AZURE_BLOB_CONTAINER_NAME:recordings}
eventhub:
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:}
name: ${AZURE_EVENTHUB_NAME:transcription-events}
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default}
# Actuator Configuration
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
base-path: /actuator
endpoint:
health:
show-details: always
show-components: always
health:
livenessState:
enabled: true
readinessState:
enabled: true
# OpenAPI Documentation
springdoc:
api-docs:
path: /v3/api-docs
swagger-ui:
path: /swagger-ui.html
tags-sorter: alpha
operations-sorter: alpha
show-actuator: false
# Logging Configuration
logging:
level:
com.unicorn.hgzero.stt: ${LOG_LEVEL_APP:DEBUG}
org.springframework.web: ${LOG_LEVEL_WEB:INFO}
org.springframework.security: ${LOG_LEVEL_SECURITY:DEBUG}
org.springframework.websocket: ${LOG_LEVEL_WEBSOCKET:DEBUG}
org.hibernate.SQL: ${LOG_LEVEL_SQL:DEBUG}
org.hibernate.type: ${LOG_LEVEL_SQL_TYPE:TRACE}
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
file:
name: ${LOG_FILE_PATH:logs/stt.log}

View File

@ -0,0 +1,29 @@
package com.unicorn.hgzero.stt;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* STT Service Application
* 음성-텍스트 변환 서비스 메인 애플리케이션
*/
@SpringBootApplication(scanBasePackages = {
"com.unicorn.hgzero.stt",
"com.unicorn.hgzero.common"
})
@EntityScan(basePackages = {
"com.unicorn.hgzero.stt.repository.entity",
"com.unicorn.hgzero.common.entity"
})
@EnableJpaRepositories(basePackages = {
"com.unicorn.hgzero.stt.repository.jpa",
"com.unicorn.hgzero.common.repository"
})
public class SttApplication {
public static void main(String[] args) {
SpringApplication.run(SttApplication.class, args);
}
}

View File

@ -0,0 +1,49 @@
package com.unicorn.hgzero.stt.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Azure Event Hub 설정
* 이벤트 발행/소비를 위한 설정
*/
@Configuration
public class EventHubConfig {
/**
* JSON 직렬화/역직렬화를 위한 ObjectMapper
*/
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
return mapper;
}
/**
* Azure Event Hub Producer 설정
* 실제 운영 환경에서는 Azure Event Hubs SDK 설정
*/
// @Bean
// public EventHubProducerClient eventHubProducerClient() {
// return new EventHubClientBuilder()
// .connectionString(connectionString)
// .buildProducerClient();
// }
/**
* Azure Event Hub Consumer 설정
* 실제 운영 환경에서는 Azure Event Hubs SDK 설정
*/
// @Bean
// public EventProcessorClient eventProcessorClient() {
// return new EventProcessorClientBuilder()
// .connectionString(connectionString)
// .consumerGroup(consumerGroup)
// .processEvent(this::processEvent)
// .processError(this::processError)
// .buildEventProcessorClient();
// }
}

View File

@ -0,0 +1,73 @@
package com.unicorn.hgzero.stt.config;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.response.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import javax.validation.ConstraintViolationException;
/**
* STT 서비스 전역 예외 처리기
* STT 관련 예외를 일관된 형태로 처리
*/
@Slf4j
@RestControllerAdvice(basePackages = "com.unicorn.hgzero.stt")
public class GlobalExceptionHandler {
/**
* 비즈니스 예외 처리
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException e) {
log.warn("비즈니스 예외 발생: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getErrorCode().getCode(), e.getMessage()));
}
/**
* Validation 예외 처리
*/
@ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
public ResponseEntity<ApiResponse<Void>> handleValidationException(Exception e) {
log.warn("유효성 검증 실패: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error("VALIDATION_ERROR", "입력값이 유효하지 않습니다"));
}
/**
* Constraint Violation 예외 처리
*/
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResponse<Void>> handleConstraintViolationException(ConstraintViolationException e) {
log.warn("제약 조건 위반: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error("CONSTRAINT_VIOLATION", "제약 조건을 위반했습니다"));
}
/**
* 파일 업로드 크기 초과 예외 처리
*/
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<ApiResponse<Void>> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
log.warn("파일 크기 초과: {}", e.getMessage());
return ResponseEntity.badRequest()
.body(ApiResponse.error("FILE_SIZE_EXCEEDED", "파일 크기가 제한을 초과했습니다"));
}
/**
* 일반 예외 처리
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneralException(Exception e) {
log.error("예상치 못한 오류 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("INTERNAL_SERVER_ERROR", "서버 내부 오류가 발생했습니다"));
}
}

View File

@ -0,0 +1,39 @@
package com.unicorn.hgzero.stt.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Swagger/OpenAPI 설정
* STT 서비스 API 문서화 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("STT Service API")
.description("Speech-to-Text 서비스 API 문서")
.version("v1.0.0")
.contact(new Contact()
.name("STT Service Team")
.email("stt-service@unicorn.com"))
.license(new License()
.name("Apache 2.0")
.url("http://www.apache.org/licenses/LICENSE-2.0.html")))
.servers(List.of(
new Server().url("http://localhost:8083").description("로컬 개발 서버"),
new Server().url("https://dev-api.unicorn.com").description("개발 서버"),
new Server().url("https://api.unicorn.com").description("운영 서버")
));
}
}

View File

@ -0,0 +1,61 @@
package com.unicorn.hgzero.stt.config;
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;
/**
* WebSocket 설정
* 실시간 음성 스트리밍을 위한 WebSocket 엔드포인트 설정
*/
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
// 실시간 STT WebSocket 엔드포인트 등록
registry.addHandler(new SttWebSocketHandler(), "/ws/stt/{sessionId}")
.setAllowedOrigins("*"); // 실제 운영 환경에서는 특정 도메인으로 제한
}
/**
* STT WebSocket 핸들러
* 실시간 음성 데이터 수신 처리
*/
private static class SttWebSocketHandler implements org.springframework.web.socket.WebSocketHandler {
@Override
public void afterConnectionEstablished(org.springframework.web.socket.WebSocketSession session) throws Exception {
System.out.println("STT WebSocket 연결 설정: " + session.getId());
// 실제로는 Azure Speech Service 스트리밍 연결 설정
}
@Override
public void handleMessage(org.springframework.web.socket.WebSocketSession session,
org.springframework.web.socket.WebSocketMessage<?> message) throws Exception {
// 실시간 음성 데이터 처리
System.out.println("음성 데이터 수신: " + message.getPayload());
// 실제로는 TranscriptionService.processAudioStream() 호출
}
@Override
public void handleTransportError(org.springframework.web.socket.WebSocketSession session,
Throwable exception) throws Exception {
System.err.println("WebSocket 전송 오류: " + exception.getMessage());
}
@Override
public void afterConnectionClosed(org.springframework.web.socket.WebSocketSession session,
org.springframework.web.socket.CloseStatus closeStatus) throws Exception {
System.out.println("STT WebSocket 연결 종료: " + session.getId());
// 리소스 정리
}
@Override
public boolean supportsPartialMessages() {
return false;
}
}
}

View File

@ -0,0 +1,153 @@
package com.unicorn.hgzero.stt.controller;
import com.unicorn.hgzero.common.response.ApiResponse;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.service.RecordingService;
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.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/**
* 녹음 관리 컨트롤러
* 음성 녹음 준비, 시작, 중지, 조회 기능을 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/stt/recordings")
@RequiredArgsConstructor
@Validated
@Tag(name = "녹음 관리", description = "음성 녹음 관리 API")
public class RecordingController {
private final RecordingService recordingService;
@Operation(
summary = "녹음 준비",
description = "회의 녹음을 위한 초기화 및 설정을 수행합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "녹음 준비 성공",
content = @Content(schema = @Schema(implementation = RecordingDto.PrepareResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 요청"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "409",
description = "이미 진행 중인 녹음 세션 존재"
)
})
@PostMapping("/prepare")
public ResponseEntity<ApiResponse<RecordingDto.PrepareResponse>> prepareRecording(
@Valid @RequestBody RecordingDto.PrepareRequest request) {
log.info("녹음 준비 요청 - meetingId: {}, sessionId: {}",
request.getMeetingId(), request.getSessionId());
RecordingDto.PrepareResponse response = recordingService.prepareRecording(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "녹음 시작",
description = "준비된 녹음 세션을 시작합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "녹음 시작 성공",
content = @Content(schema = @Schema(implementation = RecordingDto.StatusResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "녹음을 시작할 수 없는 상태"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음 세션을 찾을 수 없음"
)
})
@PostMapping("/{recordingId}/start")
public ResponseEntity<ApiResponse<RecordingDto.StatusResponse>> startRecording(
@Parameter(description = "녹음 ID", required = true)
@PathVariable String recordingId,
@Valid @RequestBody RecordingDto.StartRequest request) {
log.info("녹음 시작 요청 - recordingId: {}, startedBy: {}",
recordingId, request.getStartedBy());
RecordingDto.StatusResponse response = recordingService.startRecording(recordingId, request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "녹음 중지",
description = "진행 중인 녹음을 중지합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "녹음 중지 성공",
content = @Content(schema = @Schema(implementation = RecordingDto.StatusResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "진행 중인 녹음이 아님"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음 세션을 찾을 수 없음"
)
})
@PostMapping("/{recordingId}/stop")
public ResponseEntity<ApiResponse<RecordingDto.StatusResponse>> stopRecording(
@Parameter(description = "녹음 ID", required = true)
@PathVariable String recordingId,
@Valid @RequestBody RecordingDto.StopRequest request) {
log.info("녹음 중지 요청 - recordingId: {}, stoppedBy: {}",
recordingId, request.getStoppedBy());
RecordingDto.StatusResponse response = recordingService.stopRecording(recordingId, request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "녹음 상세 조회",
description = "녹음 세션의 상세 정보를 조회합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = RecordingDto.DetailResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음 세션을 찾을 수 없음"
)
})
@GetMapping("/{recordingId}")
public ResponseEntity<ApiResponse<RecordingDto.DetailResponse>> getRecording(
@Parameter(description = "녹음 ID", required = true)
@PathVariable String recordingId) {
log.debug("녹음 조회 요청 - recordingId: {}", recordingId);
RecordingDto.DetailResponse response = recordingService.getRecording(recordingId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,146 @@
package com.unicorn.hgzero.stt.controller;
import com.unicorn.hgzero.common.response.ApiResponse;
import com.unicorn.hgzero.stt.dto.SpeakerDto;
import com.unicorn.hgzero.stt.service.SpeakerService;
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.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
/**
* 화자 관리 컨트롤러
* 화자 식별 관리 기능을 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/stt/speakers")
@RequiredArgsConstructor
@Validated
@Tag(name = "화자 관리", description = "화자 식별 및 관리 API")
public class SpeakerController {
private final SpeakerService speakerService;
@Operation(
summary = "화자 식별",
description = "음성 데이터로부터 화자를 식별합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "화자 식별 성공",
content = @Content(schema = @Schema(implementation = SpeakerDto.IdentificationResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 요청"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음을 찾을 수 없음"
)
})
@PostMapping("/identify")
public ResponseEntity<ApiResponse<SpeakerDto.IdentificationResponse>> identifySpeaker(
@Valid @RequestBody SpeakerDto.IdentifyRequest request) {
log.info("화자 식별 요청 - recordingId: {}", request.getRecordingId());
SpeakerDto.IdentificationResponse response = speakerService.identifySpeaker(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "화자 정보 조회",
description = "화자의 상세 정보를 조회합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = SpeakerDto.DetailResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "화자를 찾을 수 없음"
)
})
@GetMapping("/{speakerId}")
public ResponseEntity<ApiResponse<SpeakerDto.DetailResponse>> getSpeaker(
@Parameter(description = "화자 ID", required = true)
@PathVariable String speakerId) {
log.debug("화자 조회 요청 - speakerId: {}", speakerId);
SpeakerDto.DetailResponse response = speakerService.getSpeaker(speakerId);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "화자 정보 수정",
description = "화자의 이름 및 사용자 연결 정보를 수정합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "수정 성공",
content = @Content(schema = @Schema(implementation = SpeakerDto.DetailResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 요청"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "화자를 찾을 수 없음"
)
})
@PutMapping("/{speakerId}")
public ResponseEntity<ApiResponse<SpeakerDto.DetailResponse>> updateSpeaker(
@Parameter(description = "화자 ID", required = true)
@PathVariable String speakerId,
@Valid @RequestBody SpeakerDto.UpdateRequest request) {
log.info("화자 정보 수정 요청 - speakerId: {}, speakerName: {}",
speakerId, request.getSpeakerName());
SpeakerDto.DetailResponse response = speakerService.updateSpeaker(speakerId, request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "녹음별 화자 목록 조회",
description = "특정 녹음에 참여한 화자 목록과 통계를 조회합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = SpeakerDto.ListResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음을 찾을 수 없음"
)
})
@GetMapping("/recordings/{recordingId}")
public ResponseEntity<ApiResponse<SpeakerDto.ListResponse>> getRecordingSpeakers(
@Parameter(description = "녹음 ID", required = true)
@PathVariable String recordingId) {
log.debug("녹음별 화자 목록 조회 요청 - recordingId: {}", recordingId);
SpeakerDto.ListResponse response = speakerService.getRecordingSpeakers(recordingId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,156 @@
package com.unicorn.hgzero.stt.controller;
import com.unicorn.hgzero.common.response.ApiResponse;
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.TranscriptSegmentDto;
import com.unicorn.hgzero.stt.service.TranscriptionService;
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.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.Valid;
/**
* 음성 변환 컨트롤러
* 실시간 배치 음성-텍스트 변환 기능을 제공
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/stt/transcription")
@RequiredArgsConstructor
@Validated
@Tag(name = "음성 변환", description = "음성-텍스트 변환 API")
public class TranscriptionController {
private final TranscriptionService transcriptionService;
@Operation(
summary = "실시간 음성 변환",
description = "실시간 음성 스트림을 텍스트로 변환합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "변환 성공",
content = @Content(schema = @Schema(implementation = TranscriptSegmentDto.Response.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 요청"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음 세션을 찾을 수 없음"
)
})
@PostMapping("/stream")
public ResponseEntity<ApiResponse<TranscriptSegmentDto.Response>> processAudioStream(
@Valid @RequestBody TranscriptionDto.StreamRequest request) {
log.info("실시간 음성 변환 요청 - recordingId: {}, timestamp: {}",
request.getRecordingId(), request.getTimestamp());
TranscriptSegmentDto.Response response = transcriptionService.processAudioStream(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "배치 음성 변환",
description = "오디오 파일을 업로드하여 배치 변환 작업을 시작합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "배치 작업 시작 성공",
content = @Content(schema = @Schema(implementation = TranscriptionDto.BatchResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 파일 형식 또는 요청"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "녹음 세션을 찾을 수 없음"
)
})
@PostMapping(value = "/batch", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<ApiResponse<TranscriptionDto.BatchResponse>> transcribeAudioBatch(
@Parameter(description = "배치 변환 요청 정보")
@Valid @RequestPart("request") TranscriptionDto.BatchRequest request,
@Parameter(description = "변환할 오디오 파일")
@RequestPart("audioFile") MultipartFile audioFile) {
log.info("배치 음성 변환 요청 - recordingId: {}, fileSize: {}",
request.getRecordingId(), audioFile.getSize());
TranscriptionDto.BatchResponse response = transcriptionService.transcribeAudioBatch(request, audioFile);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "배치 변환 완료 콜백",
description = "Azure 배치 변환 완료 시 호출되는 콜백 엔드포인트입니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "콜백 처리 성공",
content = @Content(schema = @Schema(implementation = TranscriptionDto.CompleteResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 콜백 데이터"
)
})
@PostMapping("/batch/callback")
public ResponseEntity<ApiResponse<TranscriptionDto.CompleteResponse>> processBatchCallback(
@Valid @RequestBody TranscriptionDto.BatchCallbackRequest request) {
log.info("배치 변환 콜백 처리 - jobId: {}, status: {}",
request.getJobId(), request.getStatus());
TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(request);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "변환 결과 조회",
description = "녹음의 전체 변환 결과를 조회합니다."
)
@ApiResponses(value = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = TranscriptionDto.Response.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "변환 결과를 찾을 수 없음"
)
})
@GetMapping("/{recordingId}")
public ResponseEntity<ApiResponse<TranscriptionDto.Response>> getTranscription(
@Parameter(description = "녹음 ID", required = true)
@PathVariable String recordingId,
@Parameter(description = "세그먼트 정보 포함 여부")
@RequestParam(required = false, defaultValue = "false") Boolean includeSegments,
@Parameter(description = "특정 화자 필터")
@RequestParam(required = false) String speakerId) {
log.debug("변환 결과 조회 요청 - recordingId: {}, includeSegments: {}, speakerId: {}",
recordingId, includeSegments, speakerId);
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, includeSegments, speakerId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,98 @@
package com.unicorn.hgzero.stt.domain;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* 녹음 도메인 모델
* 회의 음성 녹음 정보를 나타내는 도메인 객체
*/
@Getter
@Builder
@ToString
public class Recording {
/**
* 녹음 ID
*/
private final String recordingId;
/**
* 회의 ID
*/
private final String meetingId;
/**
* 세션 ID
*/
private final String sessionId;
/**
* 녹음 상태
*/
private final RecordingStatus status;
/**
* 시작 시간
*/
private final LocalDateTime startTime;
/**
* 종료 시간
*/
private final LocalDateTime endTime;
/**
* 녹음 시간 ()
*/
private final Integer duration;
/**
* 파일 크기 (bytes)
*/
private final Long fileSize;
/**
* 저장 경로
*/
private final String storagePath;
/**
* 언어 설정
*/
private final String language;
/**
* 화자
*/
private final Integer speakerCount;
/**
* 세그먼트
*/
private final Integer segmentCount;
/**
* 녹음 상태 열거형
*/
public enum RecordingStatus {
READY("준비"),
RECORDING("녹음중"),
STOPPED("중지됨"),
COMPLETED("완료"),
ERROR("오류");
private final String description;
RecordingStatus(String description) {
this.description = description;
}
public String getDescription() {
return description;
}
}
}

View File

@ -0,0 +1,72 @@
package com.unicorn.hgzero.stt.domain;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* 화자 도메인 모델
* 음성 화자 정보를 나타내는 도메인 객체
*/
@Getter
@Builder
@ToString
public class Speaker {
/**
* 화자 ID
*/
private final String speakerId;
/**
* 화자 이름
*/
private final String speakerName;
/**
* Azure Speaker Profile ID
*/
private final String profileId;
/**
* 연결된 사용자 ID
*/
private final String userId;
/**
* 발언 세그먼트
*/
private final Integer totalSegments;
/**
* 발언 시간 ()
*/
private final Integer totalDuration;
/**
* 평균 식별 신뢰도
*/
private final Double averageConfidence;
/**
* 최초 등장 시간
*/
private final LocalDateTime firstAppeared;
/**
* 최근 등장 시간
*/
private final LocalDateTime lastAppeared;
/**
* 생성 시간
*/
private final LocalDateTime createdAt;
/**
* 수정 시간
*/
private final LocalDateTime updatedAt;
}

View File

@ -0,0 +1,77 @@
package com.unicorn.hgzero.stt.domain;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* 변환 세그먼트 도메인 모델
* 음성 변환의 개별 세그먼트를 나타내는 도메인 객체
*/
@Getter
@Builder
@ToString
public class TranscriptSegment {
/**
* 세그먼트 ID
*/
private final String segmentId;
/**
* 변환 텍스트 ID
*/
private final String transcriptId;
/**
* 녹음 ID
*/
private final String recordingId;
/**
* 변환된 텍스트
*/
private final String text;
/**
* 화자 ID
*/
private final String speakerId;
/**
* 화자 이름
*/
private final String speakerName;
/**
* 타임스탬프 (ms)
*/
private final Long timestamp;
/**
* 발언 시간 ()
*/
private final Double duration;
/**
* 신뢰도 점수 (0-1)
*/
private final Double confidence;
/**
* 경고 플래그 (낮은 신뢰도)
*/
private final Boolean warningFlag;
/**
* 청크 인덱스
*/
private final Integer chunkIndex;
/**
* 생성 시간
*/
private final LocalDateTime createdAt;
}

View File

@ -0,0 +1,62 @@
package com.unicorn.hgzero.stt.domain;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.time.LocalDateTime;
/**
* 음성 변환 도메인 모델
* 음성-텍스트 변환 결과를 나타내는 도메인 객체
*/
@Getter
@Builder
@ToString
public class Transcription {
/**
* 변환 텍스트 ID
*/
private final String transcriptId;
/**
* 녹음 ID
*/
private final String recordingId;
/**
* 전체 변환 텍스트
*/
private final String fullText;
/**
* 세그먼트
*/
private final Integer segmentCount;
/**
* 시간 ()
*/
private final Integer totalDuration;
/**
* 평균 신뢰도 점수
*/
private final Double averageConfidence;
/**
* 화자
*/
private final Integer speakerCount;
/**
* 생성 시간
*/
private final LocalDateTime createdAt;
/**
* 완료 시간
*/
private final LocalDateTime completedAt;
}

View File

@ -0,0 +1,122 @@
package com.unicorn.hgzero.stt.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import java.time.LocalDateTime;
/**
* 녹음 관련 DTO 클래스들
*/
public class RecordingDto {
/**
* 녹음 준비 요청 DTO
*/
@Getter
@Builder
@ToString
public static class PrepareRequest {
@NotBlank(message = "회의 ID는 필수입니다")
private final String meetingId;
@NotBlank(message = "세션 ID는 필수입니다")
private final String sessionId;
private final String language;
@Min(value = 1, message = "참석자 수는 1명 이상이어야 합니다")
@Max(value = 50, message = "참석자 수는 50명을 초과할 수 없습니다")
private final Integer attendeeCount;
}
/**
* 녹음 준비 응답 DTO
*/
@Getter
@Builder
@ToString
public static class PrepareResponse {
private final String recordingId;
private final String sessionId;
private final String status;
private final String streamUrl;
private final String storagePath;
private final Integer estimatedInitTime;
}
/**
* 녹음 시작 요청 DTO
*/
@Getter
@Builder
@ToString
public static class StartRequest {
@NotBlank(message = "시작자 ID는 필수입니다")
private final String startedBy;
private final String recordingMode;
}
/**
* 녹음 중지 요청 DTO
*/
@Getter
@Builder
@ToString
public static class StopRequest {
@NotBlank(message = "중지자 ID는 필수입니다")
private final String stoppedBy;
private final String reason;
}
/**
* 녹음 상태 응답 DTO
*/
@Getter
@Builder
@ToString
public static class StatusResponse {
private final String recordingId;
private final String status;
private final LocalDateTime startTime;
private final LocalDateTime endTime;
private final Integer duration;
private final Long fileSize;
private final String storagePath;
}
/**
* 녹음 상세 응답 DTO
*/
@Getter
@Builder
@ToString
public static class DetailResponse {
private final String recordingId;
private final String meetingId;
private final String sessionId;
private final String status;
private final LocalDateTime startTime;
private final LocalDateTime endTime;
private final Integer duration;
private final Integer speakerCount;
private final Integer segmentCount;
private final String storagePath;
private final String language;
}
}

View File

@ -0,0 +1,109 @@
package com.unicorn.hgzero.stt.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import java.time.LocalDateTime;
import java.util.List;
/**
* 화자 관련 DTO 클래스들
*/
public class SpeakerDto {
/**
* 화자 식별 요청 DTO
*/
@Getter
@Builder
@ToString
public static class IdentifyRequest {
@NotBlank(message = "녹음 ID는 필수입니다")
private final String recordingId;
@NotBlank(message = "오디오 프레임은 필수입니다")
private final String audioFrame;
private final Long timestamp;
}
/**
* 화자 식별 응답 DTO
*/
@Getter
@Builder
@ToString
public static class IdentificationResponse {
private final String speakerId;
private final String speakerName;
private final Double confidence;
private final Boolean isNewSpeaker;
private final String profileId;
}
/**
* 화자 상세 응답 DTO
*/
@Getter
@Builder
@ToString
public static class DetailResponse {
private final String speakerId;
private final String speakerName;
private final String profileId;
private final String userId;
private final Integer totalSegments;
private final Integer totalDuration;
private final Double averageConfidence;
private final LocalDateTime firstAppeared;
private final LocalDateTime lastAppeared;
}
/**
* 화자 정보 업데이트 요청 DTO
*/
@Getter
@Builder
@ToString
public static class UpdateRequest {
private final String speakerName;
private final String userId;
}
/**
* 화자 목록 응답 DTO
*/
@Getter
@Builder
@ToString
public static class ListResponse {
private final String recordingId;
private final Integer speakerCount;
private final List<SpeakerSummary> speakers;
}
/**
* 화자 요약 DTO
*/
@Getter
@Builder
@ToString
public static class SpeakerSummary {
private final String speakerId;
private final String speakerName;
private final Integer segmentCount;
private final Integer totalDuration;
private final Double speakingRatio;
}
}

View File

@ -0,0 +1,88 @@
package com.unicorn.hgzero.stt.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import java.time.LocalDateTime;
/**
* 변환 세그먼트 관련 DTO 클래스들
*/
public class TranscriptSegmentDto {
/**
* 변환 세그먼트 응답 DTO
*/
@Getter
@Builder
@ToString
public static class Response {
private final String transcriptId;
private final String recordingId;
private final String text;
private final String speakerId;
private final String speakerName;
private final Long timestamp;
private final Double duration;
private final Double confidence;
private final Boolean warningFlag;
}
/**
* 변환 세그먼트 상세 응답 DTO
*/
@Getter
@Builder
@ToString
public static class Detail {
private final String transcriptId;
private final String text;
private final String speakerId;
private final String speakerName;
private final Long timestamp;
private final Double duration;
private final Double confidence;
}
/**
* 변환 세그먼트 생성 요청 DTO
*/
@Getter
@Builder
@ToString
public static class CreateRequest {
@NotBlank(message = "녹음 ID는 필수입니다")
private final String recordingId;
@NotBlank(message = "텍스트는 필수입니다")
private final String text;
@NotBlank(message = "화자 ID는 필수입니다")
private final String speakerId;
private final String speakerName;
@NotNull(message = "타임스탬프는 필수입니다")
private final Long timestamp;
@NotNull(message = "발언 시간은 필수입니다")
@DecimalMin(value = "0.0", message = "발언 시간은 0 이상이어야 합니다")
private final Double duration;
@NotNull(message = "신뢰도는 필수입니다")
@DecimalMin(value = "0.0", message = "신뢰도는 0 이상이어야 합니다")
@DecimalMax(value = "1.0", message = "신뢰도는 1 이하여야 합니다")
private final Double confidence;
private final Integer chunkIndex;
}
}

View File

@ -0,0 +1,120 @@
package com.unicorn.hgzero.stt.dto;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import java.time.LocalDateTime;
import java.util.List;
/**
* 음성 변환 관련 DTO 클래스들
*/
public class TranscriptionDto {
/**
* 실시간 음성 변환 요청 DTO
*/
@Getter
@Builder
@ToString
public static class StreamRequest {
@NotBlank(message = "녹음 ID는 필수입니다")
private final String recordingId;
@NotBlank(message = "오디오 데이터는 필수입니다")
private final String audioData;
@NotNull(message = "타임스탬프는 필수입니다")
private final Long timestamp;
private final Integer chunkIndex;
}
/**
* 배치 음성 변환 요청 DTO
*/
@Getter
@Builder
@ToString
public static class BatchRequest {
@NotBlank(message = "녹음 ID는 필수입니다")
private final String recordingId;
private final String language;
private final String callbackUrl;
}
/**
* 배치 변환 응답 DTO
*/
@Getter
@Builder
@ToString
public static class BatchResponse {
private final String jobId;
private final String recordingId;
private final String status;
private final LocalDateTime estimatedCompletionTime;
private final String callbackUrl;
}
/**
* 배치 콜백 요청 DTO
*/
@Getter
@Builder
@ToString
public static class BatchCallbackRequest {
@NotBlank(message = "작업 ID는 필수입니다")
private final String jobId;
@NotBlank(message = "상태는 필수입니다")
private final String status;
private final List<TranscriptSegmentDto.Detail> segments;
private final String error;
}
/**
* 변환 완료 응답 DTO
*/
@Getter
@Builder
@ToString
public static class CompleteResponse {
private final String jobId;
private final String recordingId;
private final String status;
private final Integer segmentCount;
private final Integer totalDuration;
private final Double averageConfidence;
}
/**
* 변환 결과 응답 DTO
*/
@Getter
@Builder
@ToString
public static class Response {
private final String recordingId;
private final String fullText;
private final Integer segmentCount;
private final Integer totalDuration;
private final Double averageConfidence;
private final Integer speakerCount;
private final List<TranscriptSegmentDto.Detail> segments;
}
}

View File

@ -0,0 +1,115 @@
package com.unicorn.hgzero.stt.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 녹음 관련 이벤트 정의
*/
public class RecordingEvent {
/**
* 녹음 시작 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class RecordingStarted {
private String eventId;
private String eventType;
private String recordingId;
private String meetingId;
private String sessionId;
private String startedBy;
private LocalDateTime startTime;
private String language;
private LocalDateTime eventTime;
public static RecordingStarted of(String recordingId, String meetingId, String sessionId,
String startedBy, String language) {
return RecordingStarted.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("RecordingStarted")
.recordingId(recordingId)
.meetingId(meetingId)
.sessionId(sessionId)
.startedBy(startedBy)
.startTime(LocalDateTime.now())
.language(language)
.eventTime(LocalDateTime.now())
.build();
}
}
/**
* 녹음 중지 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class RecordingStopped {
private String eventId;
private String eventType;
private String recordingId;
private String meetingId;
private String stoppedBy;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Integer duration;
private Long fileSize;
private String storagePath;
private LocalDateTime eventTime;
public static RecordingStopped of(String recordingId, String meetingId, String stoppedBy,
LocalDateTime startTime, Integer duration, Long fileSize, String storagePath) {
return RecordingStopped.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("RecordingStopped")
.recordingId(recordingId)
.meetingId(meetingId)
.stoppedBy(stoppedBy)
.startTime(startTime)
.endTime(LocalDateTime.now())
.duration(duration)
.fileSize(fileSize)
.storagePath(storagePath)
.eventTime(LocalDateTime.now())
.build();
}
}
/**
* 녹음 실패 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class RecordingFailed {
private String eventId;
private String eventType;
private String recordingId;
private String meetingId;
private String errorCode;
private String errorMessage;
private LocalDateTime eventTime;
public static RecordingFailed of(String recordingId, String meetingId, String errorCode, String errorMessage) {
return RecordingFailed.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("RecordingFailed")
.recordingId(recordingId)
.meetingId(meetingId)
.errorCode(errorCode)
.errorMessage(errorMessage)
.eventTime(LocalDateTime.now())
.build();
}
}
}

View File

@ -0,0 +1,120 @@
package com.unicorn.hgzero.stt.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 화자 관련 이벤트 정의
*/
public class SpeakerEvent {
/**
* 신규 화자 식별 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class NewSpeakerIdentified {
private String eventId;
private String eventType;
private String speakerId;
private String speakerName;
private String profileId;
private String recordingId;
private String meetingId;
private Double confidence;
private LocalDateTime firstAppeared;
private LocalDateTime eventTime;
public static NewSpeakerIdentified of(String speakerId, String speakerName, String profileId,
String recordingId, String meetingId, Double confidence) {
return NewSpeakerIdentified.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("NewSpeakerIdentified")
.speakerId(speakerId)
.speakerName(speakerName)
.profileId(profileId)
.recordingId(recordingId)
.meetingId(meetingId)
.confidence(confidence)
.firstAppeared(LocalDateTime.now())
.eventTime(LocalDateTime.now())
.build();
}
}
/**
* 화자 정보 업데이트 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SpeakerUpdated {
private String eventId;
private String eventType;
private String speakerId;
private String oldSpeakerName;
private String newSpeakerName;
private String oldUserId;
private String newUserId;
private String updatedBy;
private LocalDateTime eventTime;
public static SpeakerUpdated of(String speakerId, String oldSpeakerName, String newSpeakerName,
String oldUserId, String newUserId, String updatedBy) {
return SpeakerUpdated.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("SpeakerUpdated")
.speakerId(speakerId)
.oldSpeakerName(oldSpeakerName)
.newSpeakerName(newSpeakerName)
.oldUserId(oldUserId)
.newUserId(newUserId)
.updatedBy(updatedBy)
.eventTime(LocalDateTime.now())
.build();
}
}
/**
* 화자 통계 업데이트 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SpeakerStatisticsUpdated {
private String eventId;
private String eventType;
private String speakerId;
private String recordingId;
private String meetingId;
private Integer totalSegments;
private Integer totalDuration;
private Double averageConfidence;
private LocalDateTime lastAppeared;
private LocalDateTime eventTime;
public static SpeakerStatisticsUpdated of(String speakerId, String recordingId, String meetingId,
Integer totalSegments, Integer totalDuration, Double averageConfidence) {
return SpeakerStatisticsUpdated.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("SpeakerStatisticsUpdated")
.speakerId(speakerId)
.recordingId(recordingId)
.meetingId(meetingId)
.totalSegments(totalSegments)
.totalDuration(totalDuration)
.averageConfidence(averageConfidence)
.lastAppeared(LocalDateTime.now())
.eventTime(LocalDateTime.now())
.build();
}
}
}

View File

@ -0,0 +1,134 @@
package com.unicorn.hgzero.stt.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 음성 변환 관련 이벤트 정의
*/
public class TranscriptionEvent {
/**
* 실시간 변환 세그먼트 생성 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SegmentCreated {
private String eventId;
private String eventType;
private String segmentId;
private String recordingId;
private String meetingId;
private String text;
private String speakerId;
private String speakerName;
private LocalDateTime timestamp;
private Double duration;
private Double confidence;
private Boolean warningFlag;
private LocalDateTime eventTime;
public static SegmentCreated of(String segmentId, String recordingId, String meetingId,
String text, String speakerId, String speakerName,
LocalDateTime timestamp, Double duration, Double confidence, Boolean warningFlag) {
return SegmentCreated.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("SegmentCreated")
.segmentId(segmentId)
.recordingId(recordingId)
.meetingId(meetingId)
.text(text)
.speakerId(speakerId)
.speakerName(speakerName)
.timestamp(timestamp)
.duration(duration)
.confidence(confidence)
.warningFlag(warningFlag)
.eventTime(LocalDateTime.now())
.build();
}
}
/**
* 배치 변환 완료 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TranscriptionCompleted {
private String eventId;
private String eventType;
private String transcriptId;
private String recordingId;
private String meetingId;
private String fullText;
private Integer segmentCount;
private Integer totalDuration;
private Double averageConfidence;
private Integer speakerCount;
private LocalDateTime completedAt;
private LocalDateTime eventTime;
public static TranscriptionCompleted of(String transcriptId, String recordingId, String meetingId,
String fullText, Integer segmentCount, Integer totalDuration,
Double averageConfidence, Integer speakerCount) {
return TranscriptionCompleted.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("TranscriptionCompleted")
.transcriptId(transcriptId)
.recordingId(recordingId)
.meetingId(meetingId)
.fullText(fullText)
.segmentCount(segmentCount)
.totalDuration(totalDuration)
.averageConfidence(averageConfidence)
.speakerCount(speakerCount)
.completedAt(LocalDateTime.now())
.eventTime(LocalDateTime.now())
.build();
}
}
/**
* 저신뢰도 세그먼트 경고 이벤트
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class LowConfidenceWarning {
private String eventId;
private String eventType;
private String segmentId;
private String recordingId;
private String meetingId;
private String text;
private String speakerId;
private Double confidence;
private Double threshold;
private LocalDateTime eventTime;
public static LowConfidenceWarning of(String segmentId, String recordingId, String meetingId,
String text, String speakerId, Double confidence) {
return LowConfidenceWarning.builder()
.eventId(java.util.UUID.randomUUID().toString())
.eventType("LowConfidenceWarning")
.segmentId(segmentId)
.recordingId(recordingId)
.meetingId(meetingId)
.text(text)
.speakerId(speakerId)
.confidence(confidence)
.threshold(0.6)
.eventTime(LocalDateTime.now())
.build();
}
}
}

View File

@ -0,0 +1,117 @@
package com.unicorn.hgzero.stt.event.consumer;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 회의 서비스 이벤트 소비자
* 회의 관련 이벤트를 수신하여 STT 서비스에서 처리
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MeetingEventConsumer {
private final ObjectMapper objectMapper;
/**
* 회의 시작 이벤트 처리
* 회의가 시작되면 STT 준비 상태로 전환
*/
public void handleMeetingStarted(String eventData) {
try {
log.info("회의 시작 이벤트 수신: {}", eventData);
// 실제로는 MeetingStarted 이벤트 파싱
// MeetingStartedEvent event = objectMapper.readValue(eventData, MeetingStartedEvent.class);
// STT 서비스 준비 로직
prepareSttForMeeting(eventData);
} catch (Exception e) {
log.error("회의 시작 이벤트 처리 실패", e);
}
}
/**
* 회의 종료 이벤트 처리
* 회의가 종료되면 진행 중인 녹음 자동 중지
*/
public void handleMeetingEnded(String eventData) {
try {
log.info("회의 종료 이벤트 수신: {}", eventData);
// 실제로는 MeetingEnded 이벤트 파싱
// MeetingEndedEvent event = objectMapper.readValue(eventData, MeetingEndedEvent.class);
// 진행 중인 녹음 자동 중지
autoStopRecording(eventData);
} catch (Exception e) {
log.error("회의 종료 이벤트 처리 실패", e);
}
}
/**
* 참여자 입장 이벤트 처리
* 참여자가 입장하면 화자 프로필 준비
*/
public void handleParticipantJoined(String eventData) {
try {
log.info("참여자 입장 이벤트 수신: {}", eventData);
// 실제로는 ParticipantJoined 이벤트 파싱
// ParticipantJoinedEvent event = objectMapper.readValue(eventData, ParticipantJoinedEvent.class);
// 화자 프로필 준비
prepareSpeakerProfile(eventData);
} catch (Exception e) {
log.error("참여자 입장 이벤트 처리 실패", e);
}
}
/**
* STT 서비스 준비
*/
private void prepareSttForMeeting(String eventData) {
// 회의 정보 추출 STT 설정 준비
log.info("STT 서비스 준비 중 - meetingData: {}", eventData);
// 실제로는 다음과 같은 처리:
// 1. Azure Speech Service 연결 설정
// 2. WebSocket 엔드포인트 활성화
// 3. 화자 인식 모델 로드
// 4. 캐시 초기화
}
/**
* 진행 중인 녹음 자동 중지
*/
private void autoStopRecording(String eventData) {
// 진행 중인 녹음 세션 조회 자동 중지
log.info("진행 중인 녹음 자동 중지 - meetingData: {}", eventData);
// 실제로는 다음과 같은 처리:
// 1. 활성화된 녹음 세션 조회
// 2. 녹음 중지 API 호출
// 3. 최종 변환 결과 생성
// 4. 리소스 정리
}
/**
* 화자 프로필 준비
*/
private void prepareSpeakerProfile(String eventData) {
// 참여자 정보 기반 화자 프로필 준비
log.info("화자 프로필 준비 중 - participantData: {}", eventData);
// 실제로는 다음과 같은 처리:
// 1. 참여자 정보에서 사용자 ID 추출
// 2. 기존 화자 프로필 조회
// 3. Azure Speaker Recognition 프로필 연결
// 4. 화자 인식 모델 업데이트
}
}

View File

@ -0,0 +1,74 @@
package com.unicorn.hgzero.stt.event.publisher;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.concurrent.CompletableFuture;
/**
* Azure Event Hub 이벤트 발행자 구현체
* Azure Event Hubs를 통한 이벤트 발행 기능
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventHubPublisher implements EventPublisher {
private final ObjectMapper objectMapper;
@Override
public void publish(String topic, Object event) {
try {
String eventData = objectMapper.writeValueAsString(event);
// 실제로는 Azure Event Hubs SDK 사용
// EventHubProducerClient producer = createProducer(topic);
// EventDataBatch batch = producer.createBatch();
// batch.tryAdd(new EventData(eventData));
// producer.send(batch);
// 시뮬레이션
simulateEventHubPublish(topic, eventData);
log.info("이벤트 발행 완료 - topic: {}, event: {}", topic, event.getClass().getSimpleName());
} catch (Exception e) {
log.error("이벤트 발행 실패 - topic: {}, event: {}", topic, event.getClass().getSimpleName(), e);
throw new RuntimeException("이벤트 발행에 실패했습니다", e);
}
}
@Override
public void publishAsync(String topic, Object event) {
CompletableFuture.runAsync(() -> {
try {
publish(topic, event);
} catch (Exception e) {
log.error("비동기 이벤트 발행 실패 - topic: {}, event: {}", topic, event.getClass().getSimpleName(), e);
}
});
}
/**
* Azure Event Hub 발행 시뮬레이션
*/
private void simulateEventHubPublish(String topic, String eventData) {
log.debug("Event Hub 발행 시뮬레이션:");
log.debug("Topic: {}", topic);
log.debug("Event Data: {}", eventData);
// 실제로는 다음과 같은 Azure Event Hubs 코드 사용:
/*
EventHubProducerClient producer = new EventHubClientBuilder()
.connectionString(connectionString, eventHubName)
.buildProducerClient();
EventDataBatch batch = producer.createBatch();
batch.tryAdd(new EventData(eventData));
producer.send(batch);
producer.close();
*/
}
}

View File

@ -0,0 +1,24 @@
package com.unicorn.hgzero.stt.event.publisher;
/**
* 이벤트 발행자 인터페이스
* 다양한 이벤트를 Azure Event Hubs로 발행
*/
public interface EventPublisher {
/**
* 이벤트 발행
*
* @param topic 이벤트 토픽 (Event Hub 이름)
* @param event 발행할 이벤트 객체
*/
void publish(String topic, Object event);
/**
* 비동기 이벤트 발행
*
* @param topic 이벤트 토픽 (Event Hub 이름)
* @param event 발행할 이벤트 객체
*/
void publishAsync(String topic, Object event);
}

View File

@ -0,0 +1,121 @@
package com.unicorn.hgzero.stt.repository.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.stt.domain.Recording;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 녹음 엔티티
* 회의 음성 녹음 정보를 저장하는 JPA 엔티티
*/
@Entity
@Table(name = "recordings")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString
public class RecordingEntity extends BaseTimeEntity {
@Id
@Column(name = "recording_id", length = 50)
private String recordingId;
@Column(name = "meeting_id", length = 50, nullable = false)
private String meetingId;
@Column(name = "session_id", length = 50, nullable = false)
private String sessionId;
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
private Recording.RecordingStatus status;
@Column(name = "start_time")
private LocalDateTime startTime;
@Column(name = "end_time")
private LocalDateTime endTime;
@Column(name = "duration")
private Integer duration;
@Column(name = "file_size")
private Long fileSize;
@Column(name = "storage_path", length = 500)
private String storagePath;
@Column(name = "language", length = 10, nullable = false)
private String language;
@Column(name = "speaker_count")
private Integer speakerCount;
@Column(name = "segment_count")
private Integer segmentCount;
/**
* 도메인 객체로 변환
*/
public Recording toDomain() {
return Recording.builder()
.recordingId(recordingId)
.meetingId(meetingId)
.sessionId(sessionId)
.status(status)
.startTime(startTime)
.endTime(endTime)
.duration(duration)
.fileSize(fileSize)
.storagePath(storagePath)
.language(language)
.speakerCount(speakerCount)
.segmentCount(segmentCount)
.build();
}
/**
* 도메인 객체에서 엔티티 생성
*/
public static RecordingEntity fromDomain(Recording recording) {
return RecordingEntity.builder()
.recordingId(recording.getRecordingId())
.meetingId(recording.getMeetingId())
.sessionId(recording.getSessionId())
.status(recording.getStatus())
.startTime(recording.getStartTime())
.endTime(recording.getEndTime())
.duration(recording.getDuration())
.fileSize(recording.getFileSize())
.storagePath(recording.getStoragePath())
.language(recording.getLanguage())
.speakerCount(recording.getSpeakerCount())
.segmentCount(recording.getSegmentCount())
.build();
}
/**
* 녹음 상태 업데이트
*/
public void updateStatus(Recording.RecordingStatus newStatus) {
this.status = newStatus;
}
/**
* 화자 업데이트
*/
public void updateSpeakerCount(Integer speakerCount) {
this.speakerCount = speakerCount;
}
/**
* 세그먼트 업데이트
*/
public void updateSegmentCount(Integer segmentCount) {
this.segmentCount = segmentCount;
}
}

View File

@ -0,0 +1,104 @@
package com.unicorn.hgzero.stt.repository.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.stt.domain.Speaker;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 화자 엔티티
* 음성 화자 정보를 저장하는 JPA 엔티티
*/
@Entity
@Table(name = "speakers")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString
public class SpeakerEntity extends BaseTimeEntity {
@Id
@Column(name = "speaker_id", length = 50)
private String speakerId;
@Column(name = "speaker_name", length = 100)
private String speakerName;
@Column(name = "profile_id", length = 100)
private String profileId;
@Column(name = "user_id", length = 50)
private String userId;
@Column(name = "total_segments")
private Integer totalSegments;
@Column(name = "total_duration")
private Integer totalDuration;
@Column(name = "average_confidence")
private Double averageConfidence;
@Column(name = "first_appeared")
private LocalDateTime firstAppeared;
@Column(name = "last_appeared")
private LocalDateTime lastAppeared;
/**
* 도메인 객체로 변환
*/
public Speaker toDomain() {
return Speaker.builder()
.speakerId(speakerId)
.speakerName(speakerName)
.profileId(profileId)
.userId(userId)
.totalSegments(totalSegments)
.totalDuration(totalDuration)
.averageConfidence(averageConfidence)
.firstAppeared(firstAppeared)
.lastAppeared(lastAppeared)
.createdAt(getCreatedAt())
.updatedAt(getUpdatedAt())
.build();
}
/**
* 도메인 객체에서 엔티티 생성
*/
public static SpeakerEntity fromDomain(Speaker speaker) {
return SpeakerEntity.builder()
.speakerId(speaker.getSpeakerId())
.speakerName(speaker.getSpeakerName())
.profileId(speaker.getProfileId())
.userId(speaker.getUserId())
.totalSegments(speaker.getTotalSegments())
.totalDuration(speaker.getTotalDuration())
.averageConfidence(speaker.getAverageConfidence())
.firstAppeared(speaker.getFirstAppeared())
.lastAppeared(speaker.getLastAppeared())
.build();
}
/**
* 화자 정보 업데이트
*/
public void updateSpeakerInfo(String speakerName, String userId) {
this.speakerName = speakerName;
this.userId = userId;
}
/**
* 통계 정보 업데이트
*/
public void updateStatistics(Integer totalSegments, Integer totalDuration, Double averageConfidence) {
this.totalSegments = totalSegments;
this.totalDuration = totalDuration;
this.averageConfidence = averageConfidence;
this.lastAppeared = LocalDateTime.now();
}
}

View File

@ -0,0 +1,102 @@
package com.unicorn.hgzero.stt.repository.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.stt.domain.TranscriptSegment;
import jakarta.persistence.*;
import lombok.*;
/**
* 변환 세그먼트 엔티티
* 음성 변환의 개별 세그먼트를 저장하는 JPA 엔티티
*/
@Entity
@Table(name = "transcript_segments")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString
public class TranscriptSegmentEntity extends BaseTimeEntity {
@Id
@Column(name = "segment_id", length = 50)
private String segmentId;
@Column(name = "transcript_id", length = 50)
private String transcriptId;
@Column(name = "recording_id", length = 50, nullable = false)
private String recordingId;
@Lob
@Column(name = "text", columnDefinition = "TEXT", nullable = false)
private String text;
@Column(name = "speaker_id", length = 50)
private String speakerId;
@Column(name = "speaker_name", length = 100)
private String speakerName;
@Column(name = "timestamp", nullable = false)
private Long timestamp;
@Column(name = "duration")
private Double duration;
@Column(name = "confidence")
private Double confidence;
@Column(name = "warning_flag")
private Boolean warningFlag;
@Column(name = "chunk_index")
private Integer chunkIndex;
/**
* 도메인 객체로 변환
*/
public TranscriptSegment toDomain() {
return TranscriptSegment.builder()
.segmentId(segmentId)
.transcriptId(transcriptId)
.recordingId(recordingId)
.text(text)
.speakerId(speakerId)
.speakerName(speakerName)
.timestamp(timestamp)
.duration(duration)
.confidence(confidence)
.warningFlag(warningFlag)
.chunkIndex(chunkIndex)
.createdAt(getCreatedAt())
.build();
}
/**
* 도메인 객체에서 엔티티 생성
*/
public static TranscriptSegmentEntity fromDomain(TranscriptSegment segment) {
return TranscriptSegmentEntity.builder()
.segmentId(segment.getSegmentId())
.transcriptId(segment.getTranscriptId())
.recordingId(segment.getRecordingId())
.text(segment.getText())
.speakerId(segment.getSpeakerId())
.speakerName(segment.getSpeakerName())
.timestamp(segment.getTimestamp())
.duration(segment.getDuration())
.confidence(segment.getConfidence())
.warningFlag(segment.getWarningFlag())
.chunkIndex(segment.getChunkIndex())
.build();
}
/**
* 화자 정보 업데이트
*/
public void updateSpeakerInfo(String speakerId, String speakerName) {
this.speakerId = speakerId;
this.speakerName = speakerName;
}
}

View File

@ -0,0 +1,89 @@
package com.unicorn.hgzero.stt.repository.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.stt.domain.Transcription;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 음성 변환 엔티티
* 음성-텍스트 변환 결과를 저장하는 JPA 엔티티
*/
@Entity
@Table(name = "transcriptions")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@ToString
public class TranscriptionEntity extends BaseTimeEntity {
@Id
@Column(name = "transcript_id", length = 50)
private String transcriptId;
@Column(name = "recording_id", length = 50, nullable = false)
private String recordingId;
@Lob
@Column(name = "full_text", columnDefinition = "TEXT")
private String fullText;
@Column(name = "segment_count")
private Integer segmentCount;
@Column(name = "total_duration")
private Integer totalDuration;
@Column(name = "average_confidence")
private Double averageConfidence;
@Column(name = "speaker_count")
private Integer speakerCount;
@Column(name = "completed_at")
private LocalDateTime completedAt;
/**
* 도메인 객체로 변환
*/
public Transcription toDomain() {
return Transcription.builder()
.transcriptId(transcriptId)
.recordingId(recordingId)
.fullText(fullText)
.segmentCount(segmentCount)
.totalDuration(totalDuration)
.averageConfidence(averageConfidence)
.speakerCount(speakerCount)
.createdAt(getCreatedAt())
.completedAt(completedAt)
.build();
}
/**
* 도메인 객체에서 엔티티 생성
*/
public static TranscriptionEntity fromDomain(Transcription transcription) {
TranscriptionEntityBuilder builder = TranscriptionEntity.builder()
.transcriptId(transcription.getTranscriptId())
.recordingId(transcription.getRecordingId())
.fullText(transcription.getFullText())
.segmentCount(transcription.getSegmentCount())
.totalDuration(transcription.getTotalDuration())
.averageConfidence(transcription.getAverageConfidence())
.speakerCount(transcription.getSpeakerCount())
.completedAt(transcription.getCompletedAt());
return builder.build();
}
/**
* 변환 완료 처리
*/
public void complete() {
this.completedAt = LocalDateTime.now();
}
}

View File

@ -0,0 +1,52 @@
package com.unicorn.hgzero.stt.repository.jpa;
import com.unicorn.hgzero.stt.domain.Recording;
import com.unicorn.hgzero.stt.repository.entity.RecordingEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 녹음 JPA Repository
* 녹음 정보에 대한 데이터베이스 접근을 담당
*/
@Repository
public interface RecordingRepository extends JpaRepository<RecordingEntity, String> {
/**
* 회의 ID로 녹음 조회
*/
List<RecordingEntity> findByMeetingId(String meetingId);
/**
* 세션 ID로 녹음 조회
*/
Optional<RecordingEntity> findBySessionId(String sessionId);
/**
* 상태별 녹음 조회
*/
List<RecordingEntity> findByStatus(Recording.RecordingStatus status);
/**
* 진행 중인 녹음 존재 여부 확인
*/
@Query("SELECT COUNT(r) > 0 FROM RecordingEntity r WHERE r.meetingId = :meetingId AND r.status IN ('READY', 'RECORDING')")
boolean existsActiveRecordingByMeetingId(@Param("meetingId") String meetingId);
/**
* 회의별 녹음 시간 조회
*/
@Query("SELECT COALESCE(SUM(r.duration), 0) FROM RecordingEntity r WHERE r.meetingId = :meetingId AND r.status = 'COMPLETED'")
Integer getTotalDurationByMeetingId(@Param("meetingId") String meetingId);
/**
* 상태별 녹음 카운트
*/
@Query("SELECT COUNT(r) FROM RecordingEntity r WHERE r.status = :status")
Long countByStatus(@Param("status") Recording.RecordingStatus status);
}

View File

@ -0,0 +1,51 @@
package com.unicorn.hgzero.stt.repository.jpa;
import com.unicorn.hgzero.stt.repository.entity.SpeakerEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 화자 JPA Repository
* 화자 정보에 대한 데이터베이스 접근을 담당
*/
@Repository
public interface SpeakerRepository extends JpaRepository<SpeakerEntity, String> {
/**
* 사용자 ID로 화자 조회
*/
Optional<SpeakerEntity> findByUserId(String userId);
/**
* Azure Profile ID로 화자 조회
*/
Optional<SpeakerEntity> findByProfileId(String profileId);
/**
* 화자명으로 검색
*/
List<SpeakerEntity> findBySpeakerNameContaining(String speakerName);
/**
* 발언 비중이 높은 화자 조회
*/
@Query("SELECT s FROM SpeakerEntity s WHERE s.totalDuration > :minDuration ORDER BY s.totalDuration DESC")
List<SpeakerEntity> findActiveSpeakers(@Param("minDuration") Integer minDuration);
/**
* 신뢰도가 낮은 화자 조회
*/
@Query("SELECT s FROM SpeakerEntity s WHERE s.averageConfidence < :threshold ORDER BY s.averageConfidence ASC")
List<SpeakerEntity> findLowConfidenceSpeakers(@Param("threshold") Double threshold);
/**
* 사용자 ID 미지정 화자 조회
*/
@Query("SELECT s FROM SpeakerEntity s WHERE s.userId IS NULL ORDER BY s.totalDuration DESC")
List<SpeakerEntity> findUnassignedSpeakers();
}

View File

@ -0,0 +1,69 @@
package com.unicorn.hgzero.stt.repository.jpa;
import com.unicorn.hgzero.stt.repository.entity.TranscriptSegmentEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 변환 세그먼트 JPA Repository
* 변환 세그먼트에 대한 데이터베이스 접근을 담당
*/
@Repository
public interface TranscriptSegmentRepository extends JpaRepository<TranscriptSegmentEntity, String> {
/**
* 녹음 ID로 세그먼트 조회 (시간순 정렬)
*/
List<TranscriptSegmentEntity> findByRecordingIdOrderByTimestamp(String recordingId);
/**
* 화자별 세그먼트 조회
*/
List<TranscriptSegmentEntity> findBySpeakerIdOrderByTimestamp(String speakerId);
/**
* 녹음 화자별 세그먼트 조회
*/
List<TranscriptSegmentEntity> findByRecordingIdAndSpeakerIdOrderByTimestamp(String recordingId, String speakerId);
/**
* 신뢰도가 낮은 세그먼트 조회
*/
@Query("SELECT ts FROM TranscriptSegmentEntity ts WHERE ts.confidence < :threshold ORDER BY ts.confidence ASC")
List<TranscriptSegmentEntity> findLowConfidenceSegments(@Param("threshold") Double threshold);
/**
* 경고 플래그가 설정된 세그먼트 조회
*/
List<TranscriptSegmentEntity> findByWarningFlagTrueOrderByTimestamp();
/**
* 녹음별 화자 통계
*/
@Query("SELECT ts.speakerId, COUNT(ts), SUM(ts.duration), AVG(ts.confidence) " +
"FROM TranscriptSegmentEntity ts " +
"WHERE ts.recordingId = :recordingId " +
"GROUP BY ts.speakerId")
List<Object[]> getSpeakerStatisticsByRecording(@Param("recordingId") String recordingId);
/**
* 녹음별 세그먼트 카운트
*/
Long countByRecordingId(String recordingId);
/**
* 시간 범위 세그먼트 조회
*/
@Query("SELECT ts FROM TranscriptSegmentEntity ts " +
"WHERE ts.recordingId = :recordingId " +
"AND ts.timestamp BETWEEN :startTime AND :endTime " +
"ORDER BY ts.timestamp")
List<TranscriptSegmentEntity> findByRecordingIdAndTimeRange(
@Param("recordingId") String recordingId,
@Param("startTime") Long startTime,
@Param("endTime") Long endTime);
}

View File

@ -0,0 +1,47 @@
package com.unicorn.hgzero.stt.repository.jpa;
import com.unicorn.hgzero.stt.repository.entity.TranscriptionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 음성 변환 JPA Repository
* 음성 변환 결과에 대한 데이터베이스 접근을 담당
*/
@Repository
public interface TranscriptionRepository extends JpaRepository<TranscriptionEntity, String> {
/**
* 녹음 ID로 변환 결과 조회
*/
Optional<TranscriptionEntity> findByRecordingId(String recordingId);
/**
* 완료된 변환 결과 조회
*/
@Query("SELECT t FROM TranscriptionEntity t WHERE t.completedAt IS NOT NULL ORDER BY t.completedAt DESC")
List<TranscriptionEntity> findCompletedTranscriptions();
/**
* 평균 신뢰도가 낮은 변환 결과 조회
*/
@Query("SELECT t FROM TranscriptionEntity t WHERE t.averageConfidence < :threshold ORDER BY t.averageConfidence ASC")
List<TranscriptionEntity> findLowConfidenceTranscriptions(@Param("threshold") Double threshold);
/**
* 녹음 ID 목록으로 변환 결과 조회
*/
@Query("SELECT t FROM TranscriptionEntity t WHERE t.recordingId IN :recordingIds")
List<TranscriptionEntity> findByRecordingIds(@Param("recordingIds") List<String> recordingIds);
/**
* 전체 변환 결과 통계
*/
@Query("SELECT COUNT(t), AVG(t.averageConfidence), SUM(t.totalDuration) FROM TranscriptionEntity t WHERE t.completedAt IS NOT NULL")
Object[] getTranscriptionStatistics();
}

View File

@ -0,0 +1,44 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.stt.dto.RecordingDto;
/**
* 녹음 서비스 인터페이스
* 음성 녹음 관리 기능을 정의
*/
public interface RecordingService {
/**
* 회의 녹음 준비
*
* @param request 녹음 준비 요청
* @return 녹음 준비 응답
*/
RecordingDto.PrepareResponse prepareRecording(RecordingDto.PrepareRequest request);
/**
* 음성 녹음 시작
*
* @param recordingId 녹음 ID
* @param request 녹음 시작 요청
* @return 녹음 상태 응답
*/
RecordingDto.StatusResponse startRecording(String recordingId, RecordingDto.StartRequest request);
/**
* 음성 녹음 중지
*
* @param recordingId 녹음 ID
* @param request 녹음 중지 요청
* @return 녹음 상태 응답
*/
RecordingDto.StatusResponse stopRecording(String recordingId, RecordingDto.StopRequest request);
/**
* 녹음 정보 조회
*
* @param recordingId 녹음 ID
* @return 녹음 상세 응답
*/
RecordingDto.DetailResponse getRecording(String recordingId);
}

View File

@ -0,0 +1,190 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.stt.domain.Recording;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.event.RecordingEvent;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.entity.RecordingEntity;
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 녹음 서비스 구현체
* 음성 녹음 관리 기능을 구현
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class RecordingServiceImpl implements RecordingService {
private final RecordingRepository recordingRepository;
private final EventPublisher eventPublisher;
@Override
public RecordingDto.PrepareResponse prepareRecording(RecordingDto.PrepareRequest request) {
log.info("녹음 준비 시작 - meetingId: {}, sessionId: {}", request.getMeetingId(), request.getSessionId());
// 중복 녹음 세션 체크
if (recordingRepository.existsActiveRecordingByMeetingId(request.getMeetingId())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "이미 진행 중인 녹음 세션이 있습니다");
}
// 녹음 ID 생성
String recordingId = generateRecordingId();
// 녹음 엔티티 생성
RecordingEntity recording = RecordingEntity.builder()
.recordingId(recordingId)
.meetingId(request.getMeetingId())
.sessionId(request.getSessionId())
.status(Recording.RecordingStatus.READY)
.language(request.getLanguage() != null ? request.getLanguage() : "ko-KR")
.speakerCount(0)
.segmentCount(0)
.storagePath(generateStoragePath(request.getMeetingId(), request.getSessionId()))
.build();
recordingRepository.save(recording);
// WebSocket 스트리밍 URL 생성
String streamUrl = String.format("wss://api.example.com/stt/v1/ws/stt/%s", request.getSessionId());
log.info("녹음 준비 완료 - recordingId: {}", recordingId);
return RecordingDto.PrepareResponse.builder()
.recordingId(recordingId)
.sessionId(request.getSessionId())
.status("READY")
.streamUrl(streamUrl)
.storagePath(recording.getStoragePath())
.estimatedInitTime(1100)
.build();
}
@Override
public RecordingDto.StatusResponse startRecording(String recordingId, RecordingDto.StartRequest request) {
log.info("녹음 시작 - recordingId: {}, startedBy: {}", recordingId, request.getStartedBy());
RecordingEntity recording = findRecordingById(recordingId);
// 상태 검증
if (recording.getStatus() != Recording.RecordingStatus.READY) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "녹음을 시작할 수 없는 상태입니다");
}
// 녹음 시작 처리
recording.updateStatus(Recording.RecordingStatus.RECORDING);
RecordingEntity savedRecording = recordingRepository.save(recording);
// 녹음 시작 이벤트 발행
RecordingEvent.RecordingStarted event = RecordingEvent.RecordingStarted.of(
recordingId, recording.getMeetingId(), recording.getSessionId(),
request.getStartedBy(), recording.getLanguage()
);
eventPublisher.publishAsync("recording-events", event);
log.info("녹음 시작 완료 - recordingId: {}", recordingId);
return RecordingDto.StatusResponse.builder()
.recordingId(recordingId)
.status("RECORDING")
.startTime(LocalDateTime.now())
.duration(0)
.build();
}
@Override
public RecordingDto.StatusResponse stopRecording(String recordingId, RecordingDto.StopRequest request) {
log.info("녹음 중지 - recordingId: {}, stoppedBy: {}", recordingId, request.getStoppedBy());
RecordingEntity recording = findRecordingById(recordingId);
// 상태 검증
if (recording.getStatus() != Recording.RecordingStatus.RECORDING) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "진행 중인 녹음이 아닙니다");
}
// 녹음 중지 처리
recording.updateStatus(Recording.RecordingStatus.STOPPED);
LocalDateTime endTime = LocalDateTime.now();
// 녹음 시간 계산 (임시로 30분으로 설정)
Integer duration = 1800; // 실제로는 startTime과 endTime 차이 계산
Long fileSize = 172800000L; // 실제로는 Azure Blob에서 파일 크기 조회
RecordingEntity savedRecording = recordingRepository.save(recording);
// 녹음 중지 이벤트 발행
RecordingEvent.RecordingStopped event = RecordingEvent.RecordingStopped.of(
recordingId, recording.getMeetingId(), request.getStoppedBy(),
recording.getStartTime(), duration, fileSize, recording.getStoragePath()
);
eventPublisher.publishAsync("recording-events", event);
log.info("녹음 중지 완료 - recordingId: {}, duration: {}초", recordingId, duration);
return RecordingDto.StatusResponse.builder()
.recordingId(recordingId)
.status("STOPPED")
.startTime(recording.getStartTime())
.endTime(endTime)
.duration(duration)
.fileSize(fileSize)
.storagePath(recording.getStoragePath())
.build();
}
@Override
@Transactional(readOnly = true)
public RecordingDto.DetailResponse getRecording(String recordingId) {
log.debug("녹음 정보 조회 - recordingId: {}", recordingId);
RecordingEntity recording = findRecordingById(recordingId);
return RecordingDto.DetailResponse.builder()
.recordingId(recording.getRecordingId())
.meetingId(recording.getMeetingId())
.sessionId(recording.getSessionId())
.status(recording.getStatus().name())
.startTime(recording.getStartTime())
.endTime(recording.getEndTime())
.duration(recording.getDuration())
.speakerCount(recording.getSpeakerCount())
.segmentCount(recording.getSegmentCount())
.storagePath(recording.getStoragePath())
.language(recording.getLanguage())
.build();
}
/**
* 녹음 ID로 엔티티 조회
*/
private RecordingEntity findRecordingById(String recordingId) {
return recordingRepository.findById(recordingId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "녹음을 찾을 수 없습니다"));
}
/**
* 녹음 ID 생성
*/
private String generateRecordingId() {
return "REC-" + LocalDateTime.now().toString().substring(0, 10).replace("-", "") + "-" +
String.format("%03d", (int)(Math.random() * 1000));
}
/**
* 저장 경로 생성
*/
private String generateStoragePath(String meetingId, String sessionId) {
return String.format("recordings/%s/%s.wav", meetingId, sessionId);
}
}

View File

@ -0,0 +1,43 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.stt.dto.SpeakerDto;
/**
* 화자 서비스 인터페이스
* 화자 식별 관리 기능을 정의
*/
public interface SpeakerService {
/**
* 화자 식별
*
* @param request 화자 식별 요청
* @return 화자 식별 응답
*/
SpeakerDto.IdentificationResponse identifySpeaker(SpeakerDto.IdentifyRequest request);
/**
* 화자 정보 조회
*
* @param speakerId 화자 ID
* @return 화자 상세 응답
*/
SpeakerDto.DetailResponse getSpeaker(String speakerId);
/**
* 화자 정보 업데이트
*
* @param speakerId 화자 ID
* @param request 화자 업데이트 요청
* @return 화자 상세 응답
*/
SpeakerDto.DetailResponse updateSpeaker(String speakerId, SpeakerDto.UpdateRequest request);
/**
* 녹음의 화자 목록 조회
*
* @param recordingId 녹음 ID
* @return 화자 목록 응답
*/
SpeakerDto.ListResponse getRecordingSpeakers(String recordingId);
}

View File

@ -0,0 +1,218 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.stt.dto.SpeakerDto;
import com.unicorn.hgzero.stt.event.SpeakerEvent;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.entity.SpeakerEntity;
import com.unicorn.hgzero.stt.repository.jpa.SpeakerRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 화자 서비스 구현체
* 화자 식별 관리 기능을 구현
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class SpeakerServiceImpl implements SpeakerService {
private final SpeakerRepository speakerRepository;
private final TranscriptSegmentRepository segmentRepository;
private final EventPublisher eventPublisher;
@Override
public SpeakerDto.IdentificationResponse identifySpeaker(SpeakerDto.IdentifyRequest request) {
log.info("화자 식별 시작 - recordingId: {}", request.getRecordingId());
// Azure Speaker Recognition API 호출 시뮬레이션
String profileId = simulateAzureSpeakerRecognition(request.getAudioFrame());
Double confidence = simulateIdentificationConfidence();
// 기존 화자 조회
SpeakerEntity speaker = speakerRepository.findByProfileId(profileId).orElse(null);
boolean isNewSpeaker = false;
if (speaker == null) {
// 신규 화자 등록
String speakerId = generateSpeakerId();
String speakerName = "화자-" + speakerId.substring(4);
speaker = SpeakerEntity.builder()
.speakerId(speakerId)
.speakerName(speakerName)
.profileId(profileId)
.totalSegments(0)
.totalDuration(0)
.averageConfidence(confidence)
.firstAppeared(LocalDateTime.now())
.lastAppeared(LocalDateTime.now())
.build();
speakerRepository.save(speaker);
isNewSpeaker = true;
// 신규 화자 식별 이벤트 발행
SpeakerEvent.NewSpeakerIdentified event = SpeakerEvent.NewSpeakerIdentified.of(
speakerId, speakerName, profileId, request.getRecordingId(),
extractMeetingIdFromRecordingId(request.getRecordingId()), confidence
);
eventPublisher.publishAsync("speaker-events", event);
log.info("신규 화자 등록 완료 - speakerId: {}, confidence: {}", speakerId, confidence);
} else {
log.debug("기존 화자 식별 - speakerId: {}, confidence: {}", speaker.getSpeakerId(), confidence);
}
return SpeakerDto.IdentificationResponse.builder()
.speakerId(speaker.getSpeakerId())
.speakerName(speaker.getSpeakerName())
.confidence(confidence)
.isNewSpeaker(isNewSpeaker)
.profileId(profileId)
.build();
}
@Override
@Transactional(readOnly = true)
public SpeakerDto.DetailResponse getSpeaker(String speakerId) {
log.debug("화자 정보 조회 - speakerId: {}", speakerId);
SpeakerEntity speaker = findSpeakerById(speakerId);
return SpeakerDto.DetailResponse.builder()
.speakerId(speaker.getSpeakerId())
.speakerName(speaker.getSpeakerName())
.profileId(speaker.getProfileId())
.userId(speaker.getUserId())
.totalSegments(speaker.getTotalSegments())
.totalDuration(speaker.getTotalDuration())
.averageConfidence(speaker.getAverageConfidence())
.firstAppeared(speaker.getFirstAppeared())
.lastAppeared(speaker.getLastAppeared())
.build();
}
@Override
public SpeakerDto.DetailResponse updateSpeaker(String speakerId, SpeakerDto.UpdateRequest request) {
log.info("화자 정보 업데이트 - speakerId: {}", speakerId);
SpeakerEntity speaker = findSpeakerById(speakerId);
// 화자 정보 업데이트
String oldSpeakerName = speaker.getSpeakerName();
String oldUserId = speaker.getUserId();
speaker.updateSpeakerInfo(request.getSpeakerName(), request.getUserId());
SpeakerEntity savedSpeaker = speakerRepository.save(speaker);
// 화자 정보 업데이트 이벤트 발행
SpeakerEvent.SpeakerUpdated event = SpeakerEvent.SpeakerUpdated.of(
speakerId, oldSpeakerName, request.getSpeakerName(),
oldUserId, request.getUserId(), "SYSTEM"
);
eventPublisher.publishAsync("speaker-events", event);
log.info("화자 정보 업데이트 완료 - speakerId: {}, speakerName: {}",
speakerId, request.getSpeakerName());
return getSpeaker(speakerId);
}
@Override
@Transactional(readOnly = true)
public SpeakerDto.ListResponse getRecordingSpeakers(String recordingId) {
log.debug("녹음 화자 목록 조회 - recordingId: {}", recordingId);
// 녹음별 화자 통계 조회
List<Object[]> speakerStats = segmentRepository.getSpeakerStatisticsByRecording(recordingId);
List<SpeakerDto.SpeakerSummary> speakers = speakerStats.stream()
.map(stat -> {
String speakerId = (String) stat[0];
Long segmentCount = (Long) stat[1];
Double totalDuration = (Double) stat[2];
Double averageConfidence = (Double) stat[3];
// 화자 정보 조회
SpeakerEntity speaker = speakerRepository.findById(speakerId).orElse(null);
String speakerName = speaker != null ? speaker.getSpeakerName() : "알 수 없는 화자";
// 발언 비율 계산 (전체 시간 대비)
Double speakingRatio = calculateSpeakingRatio(recordingId, totalDuration);
return SpeakerDto.SpeakerSummary.builder()
.speakerId(speakerId)
.speakerName(speakerName)
.segmentCount(segmentCount.intValue())
.totalDuration(totalDuration.intValue())
.speakingRatio(speakingRatio)
.build();
})
.collect(Collectors.toList());
return SpeakerDto.ListResponse.builder()
.recordingId(recordingId)
.speakerCount(speakers.size())
.speakers(speakers)
.build();
}
/**
* 화자 ID로 엔티티 조회
*/
private SpeakerEntity findSpeakerById(String speakerId) {
return speakerRepository.findById(speakerId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "화자를 찾을 수 없습니다"));
}
/**
* 화자 ID 생성
*/
private String generateSpeakerId() {
return "SPK-" + String.format("%03d", (int)(Math.random() * 999) + 1);
}
/**
* Azure Speaker Recognition 시뮬레이션
*/
private String simulateAzureSpeakerRecognition(String audioFrame) {
// 실제로는 Azure Cognitive Services Speaker Recognition API 호출
return "PROFILE-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
/**
* 화자 식별 신뢰도 시뮬레이션
*/
private Double simulateIdentificationConfidence() {
// 실제로는 Azure Speaker Recognition API에서 반환
return 0.90 + (Math.random() * 0.10); // 0.90 ~ 1.0
}
/**
* 발언 비율 계산
*/
private Double calculateSpeakingRatio(String recordingId, Double speakerDuration) {
// 전체 녹음 시간 조회 (임시로 1800초로 설정)
Double totalDuration = 1800.0;
return speakerDuration / totalDuration;
}
/**
* 녹음 ID에서 회의 ID 추출
*/
private String extractMeetingIdFromRecordingId(String recordingId) {
// 실제로는 Recording 엔티티에서 조회
return "MEETING-" + recordingId.substring(4, 12);
}
}

View File

@ -0,0 +1,47 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.TranscriptSegmentDto;
import org.springframework.web.multipart.MultipartFile;
/**
* 음성 변환 서비스 인터페이스
* 음성-텍스트 변환 기능을 정의
*/
public interface TranscriptionService {
/**
* 실시간 음성-텍스트 변환
*
* @param request 실시간 변환 요청
* @return 변환 세그먼트 응답
*/
TranscriptSegmentDto.Response processAudioStream(TranscriptionDto.StreamRequest request);
/**
* 배치 음성-텍스트 변환
*
* @param request 배치 변환 요청
* @param audioFile 오디오 파일
* @return 배치 변환 응답
*/
TranscriptionDto.BatchResponse transcribeAudioBatch(TranscriptionDto.BatchRequest request, MultipartFile audioFile);
/**
* 배치 변환 완료 콜백 처리
*
* @param request 배치 콜백 요청
* @return 변환 완료 응답
*/
TranscriptionDto.CompleteResponse processBatchCallback(TranscriptionDto.BatchCallbackRequest request);
/**
* 변환 텍스트 전체 조회
*
* @param recordingId 녹음 ID
* @param includeSegments 세그먼트 포함 여부
* @param speakerId 화자 ID 필터
* @return 변환 결과 응답
*/
TranscriptionDto.Response getTranscription(String recordingId, Boolean includeSegments, String speakerId);
}

View File

@ -0,0 +1,380 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.TranscriptSegmentDto;
import com.unicorn.hgzero.stt.event.TranscriptionEvent;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.entity.RecordingEntity;
import com.unicorn.hgzero.stt.repository.entity.TranscriptSegmentEntity;
import com.unicorn.hgzero.stt.repository.entity.TranscriptionEntity;
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 음성 변환 서비스 구현체
* 음성-텍스트 변환 기능을 구현
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class TranscriptionServiceImpl implements TranscriptionService {
private final TranscriptionRepository transcriptionRepository;
private final TranscriptSegmentRepository segmentRepository;
private final RecordingRepository recordingRepository;
private final EventPublisher eventPublisher;
@Override
public TranscriptSegmentDto.Response processAudioStream(TranscriptionDto.StreamRequest request) {
log.info("실시간 음성 변환 처리 - recordingId: {}, timestamp: {}",
request.getRecordingId(), request.getTimestamp());
// 녹음 존재 확인
RecordingEntity recording = findRecordingById(request.getRecordingId());
// 실시간 STT 처리 (Azure Speech Service 호출)
// 실제로는 Azure Speech SDK를 사용해야
String recognizedText = simulateAzureSpeechRecognition(request.getAudioData());
String speakerId = simulateSpeakerIdentification(request.getAudioData());
Double confidence = simulateConfidenceScore();
// 세그먼트 ID 생성
String segmentId = generateSegmentId();
// 신뢰도 경고 플래그 설정
Boolean warningFlag = confidence < 0.6;
// 세그먼트 저장
TranscriptSegmentEntity segment = TranscriptSegmentEntity.builder()
.segmentId(segmentId)
.recordingId(request.getRecordingId())
.text(recognizedText)
.speakerId(speakerId)
.speakerName("화자-" + speakerId.substring(4)) // 임시 화자명
.timestamp(request.getTimestamp())
.duration(3.5) // 임시 발언 시간
.confidence(confidence)
.warningFlag(warningFlag)
.chunkIndex(request.getChunkIndex())
.build();
segmentRepository.save(segment);
// 녹음 통계 업데이트
updateRecordingStatistics(request.getRecordingId());
// 세그먼트 생성 이벤트 발행
TranscriptionEvent.SegmentCreated event = TranscriptionEvent.SegmentCreated.of(
segmentId, request.getRecordingId(), recording.getMeetingId(),
recognizedText, speakerId, "화자-" + speakerId.substring(4),
request.getTimestamp(), 3.5, confidence, warningFlag
);
eventPublisher.publishAsync("transcription-events", event);
// 저신뢰도 경고 이벤트 발행
if (warningFlag) {
TranscriptionEvent.LowConfidenceWarning warningEvent = TranscriptionEvent.LowConfidenceWarning.of(
segmentId, request.getRecordingId(), recording.getMeetingId(),
recognizedText, speakerId, confidence
);
eventPublisher.publishAsync("transcription-events", warningEvent);
}
log.info("실시간 음성 변환 완료 - segmentId: {}, confidence: {}", segmentId, confidence);
return TranscriptSegmentDto.Response.builder()
.transcriptId(segmentId)
.recordingId(request.getRecordingId())
.text(recognizedText)
.speakerId(speakerId)
.speakerName("화자-" + speakerId.substring(4))
.timestamp(request.getTimestamp())
.duration(3.5)
.confidence(confidence)
.warningFlag(warningFlag)
.build();
}
@Override
public TranscriptionDto.BatchResponse transcribeAudioBatch(TranscriptionDto.BatchRequest request, MultipartFile audioFile) {
log.info("배치 음성 변환 시작 - recordingId: {}, fileSize: {}",
request.getRecordingId(), audioFile.getSize());
// 녹음 존재 확인
RecordingEntity recording = findRecordingById(request.getRecordingId());
// 배치 작업 ID 생성
String jobId = generateJobId();
// Azure Batch Transcription Job 생성 (실제로는 Azure Speech SDK 사용)
// 여기서는 시뮬레이션
log.info("배치 음성 변환 작업 생성 완료 - jobId: {}", jobId);
return TranscriptionDto.BatchResponse.builder()
.jobId(jobId)
.recordingId(request.getRecordingId())
.status("PROCESSING")
.estimatedCompletionTime(LocalDateTime.now().plusSeconds(30))
.callbackUrl(request.getCallbackUrl())
.build();
}
@Override
public TranscriptionDto.CompleteResponse processBatchCallback(TranscriptionDto.BatchCallbackRequest request) {
log.info("배치 변환 콜백 처리 - jobId: {}, status: {}", request.getJobId(), request.getStatus());
if ("COMPLETED".equals(request.getStatus()) && request.getSegments() != null) {
// 세그먼트 저장
for (TranscriptSegmentDto.Detail segmentDto : request.getSegments()) {
String segmentId = generateSegmentId();
TranscriptSegmentEntity segment = TranscriptSegmentEntity.builder()
.segmentId(segmentId)
.transcriptId(segmentDto.getTranscriptId())
.text(segmentDto.getText())
.speakerId(segmentDto.getSpeakerId())
.speakerName(segmentDto.getSpeakerName())
.timestamp(segmentDto.getTimestamp())
.duration(segmentDto.getDuration())
.confidence(segmentDto.getConfidence())
.warningFlag(segmentDto.getConfidence() < 0.6)
.build();
segmentRepository.save(segment);
}
// 전체 텍스트 통합 변환 결과 저장
String recordingId = extractRecordingIdFromJobId(request.getJobId());
aggregateAndSaveTranscription(recordingId, request.getSegments());
}
return TranscriptionDto.CompleteResponse.builder()
.jobId(request.getJobId())
.status(request.getStatus())
.segmentCount(request.getSegments() != null ? request.getSegments().size() : 0)
.totalDuration(calculateTotalDuration(request.getSegments()))
.averageConfidence(calculateAverageConfidence(request.getSegments()))
.build();
}
@Override
@Transactional(readOnly = true)
public TranscriptionDto.Response getTranscription(String recordingId, Boolean includeSegments, String speakerId) {
log.debug("변환 텍스트 조회 - recordingId: {}, includeSegments: {}", recordingId, includeSegments);
// 변환 결과 조회
TranscriptionEntity transcription = transcriptionRepository.findByRecordingId(recordingId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "변환 결과를 찾을 수 없습니다"));
List<TranscriptSegmentDto.Detail> segments = null;
if (Boolean.TRUE.equals(includeSegments)) {
List<TranscriptSegmentEntity> segmentEntities;
if (speakerId != null) {
segmentEntities = segmentRepository.findByRecordingIdAndSpeakerIdOrderByTimestamp(recordingId, speakerId);
} else {
segmentEntities = segmentRepository.findByRecordingIdOrderByTimestamp(recordingId);
}
segments = segmentEntities.stream()
.map(this::convertToSegmentDetail)
.collect(Collectors.toList());
}
return TranscriptionDto.Response.builder()
.recordingId(recordingId)
.fullText(transcription.getFullText())
.segmentCount(transcription.getSegmentCount())
.totalDuration(transcription.getTotalDuration())
.averageConfidence(transcription.getAverageConfidence())
.speakerCount(transcription.getSpeakerCount())
.segments(segments)
.build();
}
/**
* 녹음 ID로 엔티티 조회
*/
private RecordingEntity findRecordingById(String recordingId) {
return recordingRepository.findById(recordingId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "녹음을 찾을 수 없습니다"));
}
/**
* 세그먼트 ID 생성
*/
private String generateSegmentId() {
return "TRS-SEG-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
/**
* 작업 ID 생성
*/
private String generateJobId() {
return "JOB-" + LocalDateTime.now().toString().substring(0, 10).replace("-", "") + "-" +
String.format("%03d", (int)(Math.random() * 1000));
}
/**
* Azure Speech 인식 시뮬레이션
*/
private String simulateAzureSpeechRecognition(String audioData) {
// 실제로는 Azure Speech Service SDK 호출
return "안녕하세요, 오늘 회의를 시작하겠습니다.";
}
/**
* 화자 식별 시뮬레이션
*/
private String simulateSpeakerIdentification(String audioData) {
// 실제로는 Azure Speaker Recognition API 호출
return "SPK-" + String.format("%03d", (int)(Math.random() * 999) + 1);
}
/**
* 신뢰도 점수 시뮬레이션
*/
private Double simulateConfidenceScore() {
// 실제로는 Azure Speech Service에서 반환
return 0.85 + (Math.random() * 0.15); // 0.85 ~ 1.0
}
/**
* 녹음 통계 업데이트
*/
private void updateRecordingStatistics(String recordingId) {
List<Object[]> stats = segmentRepository.getSpeakerStatisticsByRecording(recordingId);
Long segmentCount = segmentRepository.countByRecordingId(recordingId);
RecordingEntity recording = findRecordingById(recordingId);
recording.updateSpeakerCount(stats.size());
recording.updateSegmentCount(segmentCount.intValue());
recordingRepository.save(recording);
}
/**
* 전체 텍스트 통합 저장
*/
private void aggregateAndSaveTranscription(String recordingId, List<TranscriptSegmentDto.Detail> segments) {
// 전체 텍스트 생성
String fullText = segments.stream()
.map(segment -> segment.getSpeakerName() + ": " + segment.getText())
.collect(Collectors.joining("\n"));
// 통계 계산
Double averageConfidence = segments.stream()
.mapToDouble(TranscriptSegmentDto.Detail::getConfidence)
.average()
.orElse(0.0);
Integer totalDuration = segments.stream()
.mapToInt(segment -> segment.getDuration().intValue())
.sum();
Integer speakerCount = (int) segments.stream()
.map(TranscriptSegmentDto.Detail::getSpeakerId)
.distinct()
.count();
// 변환 결과 저장
TranscriptionEntity transcription = TranscriptionEntity.builder()
.transcriptId(generateTranscriptId())
.recordingId(recordingId)
.fullText(fullText)
.segmentCount(segments.size())
.totalDuration(totalDuration)
.averageConfidence(averageConfidence)
.speakerCount(speakerCount)
.completedAt(LocalDateTime.now())
.build();
transcriptionRepository.save(transcription);
// 변환 완료 이벤트 발행
TranscriptionEvent.TranscriptionCompleted event = TranscriptionEvent.TranscriptionCompleted.of(
transcription.getTranscriptId(), recordingId, extractMeetingIdFromRecordingId(recordingId),
fullText, segments.size(), totalDuration, averageConfidence, speakerCount
);
eventPublisher.publishAsync("transcription-events", event);
}
/**
* 변환 ID 생성
*/
private String generateTranscriptId() {
return "TRS-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
}
/**
* 작업 ID에서 녹음 ID 추출
*/
private String extractRecordingIdFromJobId(String jobId) {
// 실제로는 작업 테이블에서 조회
return "REC-20250123-001";
}
/**
* 녹음 ID에서 회의 ID 추출
*/
private String extractMeetingIdFromRecordingId(String recordingId) {
try {
RecordingEntity recording = recordingRepository.findById(recordingId).orElse(null);
return recording != null ? recording.getMeetingId() : "MEETING-DEFAULT";
} catch (Exception e) {
return "MEETING-DEFAULT";
}
}
/**
* 시간 계산
*/
private Integer calculateTotalDuration(List<TranscriptSegmentDto.Detail> segments) {
if (segments == null) return 0;
return segments.stream()
.mapToInt(segment -> segment.getDuration().intValue())
.sum();
}
/**
* 평균 신뢰도 계산
*/
private Double calculateAverageConfidence(List<TranscriptSegmentDto.Detail> segments) {
if (segments == null || segments.isEmpty()) return 0.0;
return segments.stream()
.mapToDouble(TranscriptSegmentDto.Detail::getConfidence)
.average()
.orElse(0.0);
}
/**
* 세그먼트 엔티티를 DTO로 변환
*/
private TranscriptSegmentDto.Detail convertToSegmentDetail(TranscriptSegmentEntity entity) {
return TranscriptSegmentDto.Detail.builder()
.transcriptId(entity.getTranscriptId())
.text(entity.getText())
.speakerId(entity.getSpeakerId())
.speakerName(entity.getSpeakerName())
.timestamp(entity.getTimestamp())
.duration(entity.getDuration())
.confidence(entity.getConfidence())
.build();
}
}

View File

@ -0,0 +1,23 @@
package com.unicorn.hgzero.stt;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
/**
* STT 애플리케이션 통합 테스트
* 전체 애플리케이션 컨텍스트 로딩 기본 설정 검증
*/
@SpringBootTest
@ActiveProfiles("test")
@DisplayName("STT 애플리케이션 테스트")
class SttApplicationTest {
@Test
@DisplayName("애플리케이션 컨텍스트 로딩 성공")
void contextLoads() {
// 애플리케이션 컨텍스트가 정상적으로 로딩되는지 확인
// 테스트는 모든 Bean이 정상적으로 생성되고 주입되는지 검증
}
}

View File

@ -0,0 +1,225 @@
package com.unicorn.hgzero.stt.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.service.RecordingService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* 녹음 컨트롤러 통합 테스트
*/
@WebMvcTest(RecordingController.class)
@DisplayName("녹음 컨트롤러 테스트")
class RecordingControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private RecordingService recordingService;
private RecordingDto.PrepareRequest prepareRequest;
private RecordingDto.PrepareResponse prepareResponse;
@BeforeEach
void setUp() {
prepareRequest = RecordingDto.PrepareRequest.builder()
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.language("ko-KR")
.build();
prepareResponse = RecordingDto.PrepareResponse.builder()
.recordingId("REC-20250123-001")
.sessionId("SESSION-001")
.status("READY")
.streamUrl("wss://api.example.com/stt/v1/ws/stt/SESSION-001")
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.estimatedInitTime(1100)
.build();
}
@Test
@DisplayName("녹음 준비 API 성공")
void prepareRecording_Success() throws Exception {
// Given
when(recordingService.prepareRecording(any(RecordingDto.PrepareRequest.class)))
.thenReturn(prepareResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(prepareRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value("REC-20250123-001"))
.andExpect(jsonPath("$.data.sessionId").value("SESSION-001"))
.andExpect(jsonPath("$.data.status").value("READY"))
.andExpect(jsonPath("$.data.streamUrl").value("wss://api.example.com/stt/v1/ws/stt/SESSION-001"))
.andExpect(jsonPath("$.data.estimatedInitTime").value(1100));
verify(recordingService).prepareRecording(any(RecordingDto.PrepareRequest.class));
}
@Test
@DisplayName("녹음 준비 API 실패 - 유효성 검증")
void prepareRecording_ValidationFailure() throws Exception {
// Given
RecordingDto.PrepareRequest invalidRequest = RecordingDto.PrepareRequest.builder()
.meetingId("") //
.sessionId("SESSION-001")
.build();
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest());
verify(recordingService, never()).prepareRecording(any());
}
@Test
@DisplayName("녹음 시작 API 성공")
void startRecording_Success() throws Exception {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("user001")
.build();
RecordingDto.StatusResponse statusResponse = RecordingDto.StatusResponse.builder()
.recordingId(recordingId)
.status("RECORDING")
.startTime(LocalDateTime.now())
.duration(0)
.build();
when(recordingService.startRecording(eq(recordingId), any(RecordingDto.StartRequest.class)))
.thenReturn(statusResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/start", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(startRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.status").value("RECORDING"))
.andExpect(jsonPath("$.data.duration").value(0));
verify(recordingService).startRecording(eq(recordingId), any(RecordingDto.StartRequest.class));
}
@Test
@DisplayName("녹음 중지 API 성공")
void stopRecording_Success() throws Exception {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StopRequest stopRequest = RecordingDto.StopRequest.builder()
.stoppedBy("user001")
.build();
RecordingDto.StatusResponse statusResponse = RecordingDto.StatusResponse.builder()
.recordingId(recordingId)
.status("STOPPED")
.startTime(LocalDateTime.now().minusMinutes(30))
.endTime(LocalDateTime.now())
.duration(1800)
.fileSize(172800000L)
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.build();
when(recordingService.stopRecording(eq(recordingId), any(RecordingDto.StopRequest.class)))
.thenReturn(statusResponse);
// When & Then
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/stop", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(stopRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.status").value("STOPPED"))
.andExpect(jsonPath("$.data.duration").value(1800))
.andExpect(jsonPath("$.data.fileSize").value(172800000L));
verify(recordingService).stopRecording(eq(recordingId), any(RecordingDto.StopRequest.class));
}
@Test
@DisplayName("녹음 조회 API 성공")
void getRecording_Success() throws Exception {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.DetailResponse detailResponse = RecordingDto.DetailResponse.builder()
.recordingId(recordingId)
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.status("STOPPED")
.startTime(LocalDateTime.now().minusMinutes(30))
.endTime(LocalDateTime.now())
.duration(1800)
.speakerCount(3)
.segmentCount(45)
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.language("ko-KR")
.build();
when(recordingService.getRecording(recordingId)).thenReturn(detailResponse);
// When & Then
mockMvc.perform(get("/api/v1/stt/recordings/{recordingId}", recordingId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.meetingId").value("MEETING-001"))
.andExpect(jsonPath("$.data.sessionId").value("SESSION-001"))
.andExpect(jsonPath("$.data.status").value("STOPPED"))
.andExpect(jsonPath("$.data.duration").value(1800))
.andExpect(jsonPath("$.data.speakerCount").value(3))
.andExpect(jsonPath("$.data.segmentCount").value(45))
.andExpect(jsonPath("$.data.language").value("ko-KR"));
verify(recordingService).getRecording(recordingId);
}
@Test
@DisplayName("존재하지 않는 녹음 조회 실패")
void getRecording_NotFound() throws Exception {
// Given
String recordingId = "REC-NOTFOUND-001";
when(recordingService.getRecording(recordingId))
.thenThrow(new com.unicorn.hgzero.common.exception.BusinessException(
com.unicorn.hgzero.common.exception.ErrorCode.ENTITY_NOT_FOUND,
"녹음을 찾을 수 없습니다"
));
// When & Then
mockMvc.perform(get("/api/v1/stt/recordings/{recordingId}", recordingId))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").value("녹음을 찾을 수 없습니다"));
verify(recordingService).getRecording(recordingId);
}
}

View File

@ -0,0 +1,185 @@
package com.unicorn.hgzero.stt.integration;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.SpeakerDto;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureWebMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.transaction.annotation.Transactional;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/**
* STT API 통합 테스트
* 전체 워크플로우 시나리오 테스트
*/
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureWebMvc
@ActiveProfiles("test")
@Transactional
@DisplayName("STT API 통합 테스트")
class SttApiIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
@DisplayName("전체 STT 워크플로우 통합 테스트")
void fullSttWorkflowIntegrationTest() throws Exception {
// 1단계: 녹음 준비
RecordingDto.PrepareRequest prepareRequest = RecordingDto.PrepareRequest.builder()
.meetingId("MEETING-INTEGRATION-001")
.sessionId("SESSION-INTEGRATION-001")
.language("ko-KR")
.build();
MvcResult prepareResult = mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(prepareRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("READY"))
.andReturn();
// 응답에서 recordingId 추출
String prepareResponseJson = prepareResult.getResponse().getContentAsString();
// JSON 파싱하여 recordingId 추출 (실제로는 JsonPath 라이브러리 사용)
String recordingId = "REC-20250123-001"; // 테스트용 하드코딩
// 2단계: 녹음 시작
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("integration-test-user")
.build();
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/start", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(startRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("RECORDING"));
// 3단계: 실시간 음성 변환 (시뮬레이션)
TranscriptionDto.StreamRequest streamRequest = TranscriptionDto.StreamRequest.builder()
.recordingId(recordingId)
.audioData("dGVzdCBhdWRpbyBkYXRh") // base64 encoded "test audio data"
.timestamp(System.currentTimeMillis())
.chunkIndex(1)
.build();
mockMvc.perform(post("/api/v1/stt/transcription/stream")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(streamRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.text").exists())
.andExpect(jsonPath("$.data.confidence").exists());
// 4단계: 화자 식별
SpeakerDto.IdentifyRequest identifyRequest = SpeakerDto.IdentifyRequest.builder()
.recordingId(recordingId)
.audioFrame("dGVzdCBhdWRpbyBmcmFtZQ==") // base64 encoded "test audio frame"
.build();
mockMvc.perform(post("/api/v1/stt/speakers/identify")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(identifyRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.speakerId").exists())
.andExpect(jsonPath("$.data.confidence").exists());
// 5단계: 녹음 중지
RecordingDto.StopRequest stopRequest = RecordingDto.StopRequest.builder()
.stoppedBy("integration-test-user")
.build();
mockMvc.perform(post("/api/v1/stt/recordings/{recordingId}/stop", recordingId)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(stopRequest)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.status").value("STOPPED"))
.andExpect(jsonPath("$.data.duration").exists());
// 6단계: 녹음 정보 조회
mockMvc.perform(get("/api/v1/stt/recordings/{recordingId}", recordingId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.status").value("STOPPED"));
// 7단계: 변환 결과 조회 (세그먼트 포함)
mockMvc.perform(get("/api/v1/stt/transcription/{recordingId}", recordingId)
.param("includeSegments", "true"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.fullText").exists());
// 8단계: 녹음별 화자 목록 조회
mockMvc.perform(get("/api/v1/stt/speakers/recordings/{recordingId}", recordingId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.recordingId").value(recordingId))
.andExpect(jsonPath("$.data.speakerCount").exists());
}
@Test
@DisplayName("에러 케이스 통합 테스트")
void errorCasesIntegrationTest() throws Exception {
// 존재하지 않는 녹음 시작 시도
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("test-user")
.build();
mockMvc.perform(post("/api/v1/stt/recordings/NONEXISTENT-001/start")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(startRequest)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false))
.andExpect(jsonPath("$.message").exists());
// 잘못된 요청 데이터로 녹음 준비 시도
RecordingDto.PrepareRequest invalidRequest = RecordingDto.PrepareRequest.builder()
.meetingId("") // meetingId
.sessionId("SESSION-001")
.build();
mockMvc.perform(post("/api/v1/stt/recordings/prepare")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(invalidRequest)))
.andExpect(status().isBadRequest());
// 존재하지 않는 변환 결과 조회
mockMvc.perform(get("/api/v1/stt/transcription/NONEXISTENT-001"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.success").value(false));
}
@Test
@DisplayName("Swagger UI 접근 테스트")
void swaggerUiAccessTest() throws Exception {
// Swagger UI 페이지 접근 가능 여부 확인
mockMvc.perform(get("/swagger-ui/index.html"))
.andExpect(status().isOk());
// OpenAPI JSON 엔드포인트 확인
mockMvc.perform(get("/v3/api-docs"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON));
}
}

View File

@ -0,0 +1,221 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.stt.domain.Recording;
import com.unicorn.hgzero.stt.dto.RecordingDto;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.entity.RecordingEntity;
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 녹음 서비스 단위 테스트
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("녹음 서비스 테스트")
class RecordingServiceTest {
@Mock
private RecordingRepository recordingRepository;
@Mock
private EventPublisher eventPublisher;
@InjectMocks
private RecordingServiceImpl recordingService;
private RecordingDto.PrepareRequest prepareRequest;
private RecordingEntity recordingEntity;
@BeforeEach
void setUp() {
prepareRequest = RecordingDto.PrepareRequest.builder()
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.language("ko-KR")
.build();
recordingEntity = RecordingEntity.builder()
.recordingId("REC-20250123-001")
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.status(Recording.RecordingStatus.READY)
.language("ko-KR")
.speakerCount(0)
.segmentCount(0)
.storagePath("recordings/MEETING-001/SESSION-001.wav")
.build();
}
@Test
@DisplayName("녹음 준비 성공")
void prepareRecording_Success() {
// Given
when(recordingRepository.existsActiveRecordingByMeetingId(anyString())).thenReturn(false);
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
RecordingDto.PrepareResponse response = recordingService.prepareRecording(prepareRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getSessionId()).isEqualTo("SESSION-001");
assertThat(response.getStatus()).isEqualTo("READY");
assertThat(response.getStreamUrl()).contains("SESSION-001");
verify(recordingRepository).existsActiveRecordingByMeetingId("MEETING-001");
verify(recordingRepository).save(any(RecordingEntity.class));
}
@Test
@DisplayName("중복 녹음 세션 예외")
void prepareRecording_DuplicateSession() {
// Given
when(recordingRepository.existsActiveRecordingByMeetingId(anyString())).thenReturn(true);
// When & Then
assertThatThrownBy(() -> recordingService.prepareRecording(prepareRequest))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("이미 진행 중인 녹음 세션이 있습니다");
verify(recordingRepository).existsActiveRecordingByMeetingId("MEETING-001");
verify(recordingRepository, never()).save(any());
}
@Test
@DisplayName("녹음 시작 성공")
void startRecording_Success() {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("user001")
.build();
when(recordingRepository.findById(recordingId)).thenReturn(Optional.of(recordingEntity));
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
RecordingDto.StatusResponse response = recordingService.startRecording(recordingId, startRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo(recordingId);
assertThat(response.getStatus()).isEqualTo("RECORDING");
assertThat(response.getDuration()).isEqualTo(0);
verify(recordingRepository).findById(recordingId);
verify(recordingRepository).save(any(RecordingEntity.class));
verify(eventPublisher).publishAsync(eq("recording-events"), any());
}
@Test
@DisplayName("녹음 시작 실패 - 잘못된 상태")
void startRecording_InvalidStatus() {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StartRequest startRequest = RecordingDto.StartRequest.builder()
.startedBy("user001")
.build();
RecordingEntity recordingEntity = RecordingEntity.builder()
.recordingId(recordingId)
.status(Recording.RecordingStatus.RECORDING) // 이미 녹음
.build();
when(recordingRepository.findById(recordingId)).thenReturn(Optional.of(recordingEntity));
// When & Then
assertThatThrownBy(() -> recordingService.startRecording(recordingId, startRequest))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("녹음을 시작할 수 없는 상태입니다");
verify(recordingRepository).findById(recordingId);
verify(recordingRepository, never()).save(any());
verify(eventPublisher, never()).publishAsync(any(), any());
}
@Test
@DisplayName("녹음 중지 성공")
void stopRecording_Success() {
// Given
String recordingId = "REC-20250123-001";
RecordingDto.StopRequest stopRequest = RecordingDto.StopRequest.builder()
.stoppedBy("user001")
.build();
RecordingEntity recordingEntity = RecordingEntity.builder()
.recordingId(recordingId)
.meetingId("MEETING-001")
.status(Recording.RecordingStatus.RECORDING)
.startTime(LocalDateTime.now().minusMinutes(30))
.build();
when(recordingRepository.findById(recordingId)).thenReturn(Optional.of(recordingEntity));
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
RecordingDto.StatusResponse response = recordingService.stopRecording(recordingId, stopRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo(recordingId);
assertThat(response.getStatus()).isEqualTo("STOPPED");
assertThat(response.getDuration()).isEqualTo(1800);
assertThat(response.getFileSize()).isEqualTo(172800000L);
verify(recordingRepository).findById(recordingId);
verify(recordingRepository).save(any(RecordingEntity.class));
verify(eventPublisher).publishAsync(eq("recording-events"), any());
}
@Test
@DisplayName("녹음 정보 조회 성공")
void getRecording_Success() {
// Given
String recordingId = "REC-20250123-001";
when(recordingRepository.findById(recordingId)).thenReturn(Optional.of(recordingEntity));
// When
RecordingDto.DetailResponse response = recordingService.getRecording(recordingId);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo(recordingId);
assertThat(response.getMeetingId()).isEqualTo("MEETING-001");
assertThat(response.getSessionId()).isEqualTo("SESSION-001");
assertThat(response.getStatus()).isEqualTo("READY");
assertThat(response.getLanguage()).isEqualTo("ko-KR");
verify(recordingRepository).findById(recordingId);
}
@Test
@DisplayName("존재하지 않는 녹음 조회 예외")
void getRecording_NotFound() {
// Given
String recordingId = "REC-NOTFOUND-001";
when(recordingRepository.findById(recordingId)).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> recordingService.getRecording(recordingId))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("녹음을 찾을 수 없습니다");
verify(recordingRepository).findById(recordingId);
}
}

View File

@ -0,0 +1,310 @@
package com.unicorn.hgzero.stt.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.stt.dto.TranscriptionDto;
import com.unicorn.hgzero.stt.dto.TranscriptSegmentDto;
import com.unicorn.hgzero.stt.event.publisher.EventPublisher;
import com.unicorn.hgzero.stt.repository.entity.RecordingEntity;
import com.unicorn.hgzero.stt.repository.entity.TranscriptSegmentEntity;
import com.unicorn.hgzero.stt.repository.entity.TranscriptionEntity;
import com.unicorn.hgzero.stt.repository.jpa.RecordingRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptSegmentRepository;
import com.unicorn.hgzero.stt.repository.jpa.TranscriptionRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/**
* 음성 변환 서비스 단위 테스트
*/
@ExtendWith(MockitoExtension.class)
@DisplayName("음성 변환 서비스 테스트")
class TranscriptionServiceTest {
@Mock
private TranscriptionRepository transcriptionRepository;
@Mock
private TranscriptSegmentRepository segmentRepository;
@Mock
private RecordingRepository recordingRepository;
@Mock
private EventPublisher eventPublisher;
@InjectMocks
private TranscriptionServiceImpl transcriptionService;
private RecordingEntity recordingEntity;
private TranscriptionDto.StreamRequest streamRequest;
@BeforeEach
void setUp() {
recordingEntity = RecordingEntity.builder()
.recordingId("REC-20250123-001")
.meetingId("MEETING-001")
.sessionId("SESSION-001")
.build();
streamRequest = TranscriptionDto.StreamRequest.builder()
.recordingId("REC-20250123-001")
.audioData("base64-encoded-audio-data")
.timestamp(System.currentTimeMillis())
.chunkIndex(1)
.build();
}
@Test
@DisplayName("실시간 음성 변환 성공")
void processAudioStream_Success() {
// Given
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of());
when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L);
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
TranscriptSegmentDto.Response response = transcriptionService.processAudioStream(streamRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo("REC-20250123-001");
assertThat(response.getText()).isNotEmpty();
assertThat(response.getConfidence()).isGreaterThan(0.8);
assertThat(response.getSpeakerId()).isNotEmpty();
verify(recordingRepository).findById("REC-20250123-001");
verify(segmentRepository).save(any(TranscriptSegmentEntity.class));
verify(eventPublisher).publishAsync(eq("transcription-events"), any());
}
@Test
@DisplayName("실시간 음성 변환 실패 - 녹음 없음")
void processAudioStream_RecordingNotFound() {
// Given
when(recordingRepository.findById(anyString())).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> transcriptionService.processAudioStream(streamRequest))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("녹음을 찾을 수 없습니다");
verify(recordingRepository).findById("REC-20250123-001");
verify(segmentRepository, never()).save(any());
verify(eventPublisher, never()).publishAsync(any(), any());
}
@Test
@DisplayName("저신뢰도 세그먼트 경고 이벤트 발행")
void processAudioStream_LowConfidenceWarning() {
// Given
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
when(segmentRepository.getSpeakerStatisticsByRecording(anyString())).thenReturn(List.of());
when(segmentRepository.countByRecordingId(anyString())).thenReturn(1L);
when(recordingRepository.save(any(RecordingEntity.class))).thenReturn(recordingEntity);
// When
TranscriptSegmentDto.Response response = transcriptionService.processAudioStream(streamRequest);
// Then
// 저신뢰도인 경우 경고 이벤트도 발행되는지 확인
if (response.getWarningFlag()) {
verify(eventPublisher, times(2)).publishAsync(eq("transcription-events"), any());
} else {
verify(eventPublisher, times(1)).publishAsync(eq("transcription-events"), any());
}
}
@Test
@DisplayName("배치 음성 변환 작업 시작 성공")
void transcribeAudioBatch_Success() {
// Given
TranscriptionDto.BatchRequest batchRequest = TranscriptionDto.BatchRequest.builder()
.recordingId("REC-20250123-001")
.callbackUrl("https://api.example.com/callback")
.build();
MockMultipartFile audioFile = new MockMultipartFile(
"audioFile", "test.wav", "audio/wav", "test audio content".getBytes()
);
when(recordingRepository.findById(anyString())).thenReturn(Optional.of(recordingEntity));
// When
TranscriptionDto.BatchResponse response = transcriptionService.transcribeAudioBatch(batchRequest, audioFile);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo("REC-20250123-001");
assertThat(response.getStatus()).isEqualTo("PROCESSING");
assertThat(response.getJobId()).isNotEmpty();
assertThat(response.getCallbackUrl()).isEqualTo("https://api.example.com/callback");
assertThat(response.getEstimatedCompletionTime()).isAfter(LocalDateTime.now());
verify(recordingRepository).findById("REC-20250123-001");
}
@Test
@DisplayName("배치 변환 완료 콜백 처리 성공")
void processBatchCallback_Success() {
// Given
List<TranscriptSegmentDto.Detail> segments = List.of(
TranscriptSegmentDto.Detail.builder()
.transcriptId("TRS-001")
.text("안녕하세요")
.speakerId("SPK-001")
.speakerName("화자-001")
.timestamp(System.currentTimeMillis())
.duration(2.5)
.confidence(0.95)
.build(),
TranscriptSegmentDto.Detail.builder()
.transcriptId("TRS-002")
.text("회의를 시작하겠습니다")
.speakerId("SPK-002")
.speakerName("화자-002")
.timestamp(System.currentTimeMillis() + 3000)
.duration(3.2)
.confidence(0.92)
.build()
);
TranscriptionDto.BatchCallbackRequest callbackRequest = TranscriptionDto.BatchCallbackRequest.builder()
.jobId("JOB-20250123-001")
.status("COMPLETED")
.segments(segments)
.build();
when(segmentRepository.save(any(TranscriptSegmentEntity.class))).thenReturn(any());
when(transcriptionRepository.save(any(TranscriptionEntity.class))).thenReturn(any());
// When
TranscriptionDto.CompleteResponse response = transcriptionService.processBatchCallback(callbackRequest);
// Then
assertThat(response).isNotNull();
assertThat(response.getJobId()).isEqualTo("JOB-20250123-001");
assertThat(response.getStatus()).isEqualTo("COMPLETED");
assertThat(response.getSegmentCount()).isEqualTo(2);
assertThat(response.getTotalDuration()).isEqualTo(5); // 2.5 + 3.2 반올림
assertThat(response.getAverageConfidence()).isEqualTo(0.935); // (0.95 + 0.92) / 2
verify(segmentRepository, times(2)).save(any(TranscriptSegmentEntity.class));
verify(transcriptionRepository).save(any(TranscriptionEntity.class));
verify(eventPublisher).publishAsync(eq("transcription-events"), any());
}
@Test
@DisplayName("변환 결과 조회 성공")
void getTranscription_Success() {
// Given
String recordingId = "REC-20250123-001";
TranscriptionEntity transcriptionEntity = TranscriptionEntity.builder()
.transcriptId("TRS-FULL-001")
.recordingId(recordingId)
.fullText("안녕하세요, 회의를 시작하겠습니다.")
.segmentCount(2)
.totalDuration(300)
.averageConfidence(0.92)
.speakerCount(2)
.build();
when(transcriptionRepository.findByRecordingId(recordingId))
.thenReturn(Optional.of(transcriptionEntity));
// When
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, false, null);
// Then
assertThat(response).isNotNull();
assertThat(response.getRecordingId()).isEqualTo(recordingId);
assertThat(response.getFullText()).isEqualTo("안녕하세요, 회의를 시작하겠습니다.");
assertThat(response.getSegmentCount()).isEqualTo(2);
assertThat(response.getTotalDuration()).isEqualTo(300);
assertThat(response.getAverageConfidence()).isEqualTo(0.92);
assertThat(response.getSpeakerCount()).isEqualTo(2);
assertThat(response.getSegments()).isNull(); // includeSegments = false
verify(transcriptionRepository).findByRecordingId(recordingId);
}
@Test
@DisplayName("세그먼트 포함 변환 결과 조회 성공")
void getTranscription_WithSegments_Success() {
// Given
String recordingId = "REC-20250123-001";
TranscriptionEntity transcriptionEntity = TranscriptionEntity.builder()
.transcriptId("TRS-FULL-001")
.recordingId(recordingId)
.fullText("안녕하세요, 회의를 시작하겠습니다.")
.segmentCount(2)
.totalDuration(300)
.averageConfidence(0.92)
.speakerCount(2)
.build();
List<TranscriptSegmentEntity> segmentEntities = List.of(
TranscriptSegmentEntity.builder()
.segmentId("SEG-001")
.recordingId(recordingId)
.text("안녕하세요")
.speakerId("SPK-001")
.speakerName("화자-001")
.timestamp(System.currentTimeMillis())
.duration(2.5)
.confidence(0.95)
.build()
);
when(transcriptionRepository.findByRecordingId(recordingId))
.thenReturn(Optional.of(transcriptionEntity));
when(segmentRepository.findByRecordingIdOrderByTimestamp(recordingId))
.thenReturn(segmentEntities);
// When
TranscriptionDto.Response response = transcriptionService.getTranscription(recordingId, true, null);
// Then
assertThat(response).isNotNull();
assertThat(response.getSegments()).isNotNull();
assertThat(response.getSegments()).hasSize(1);
assertThat(response.getSegments().get(0).getText()).isEqualTo("안녕하세요");
verify(transcriptionRepository).findByRecordingId(recordingId);
verify(segmentRepository).findByRecordingIdOrderByTimestamp(recordingId);
}
@Test
@DisplayName("변환 결과 조회 실패 - 결과 없음")
void getTranscription_NotFound() {
// Given
String recordingId = "REC-NOTFOUND-001";
when(transcriptionRepository.findByRecordingId(recordingId)).thenReturn(Optional.empty());
// When & Then
assertThatThrownBy(() -> transcriptionService.getTranscription(recordingId, false, null))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("변환 결과를 찾을 수 없습니다");
verify(transcriptionRepository).findByRecordingId(recordingId);
}
}

View File

@ -0,0 +1,55 @@
# STT 서비스 테스트 설정
spring:
profiles:
active: test
# 데이터베이스 설정 (H2 인메모리)
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
# JPA 설정
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
format_sql: true
# Redis 설정 (임베디드)
redis:
host: localhost
port: 6370
timeout: 2000ms
# JWT 설정
security:
jwt:
secret: test-secret-key-for-jwt-token-generation-test
expiration: 86400
# Azure 서비스 설정 (테스트용 더미)
azure:
speech:
subscription-key: test-key
region: koreacentral
endpoint: https://test.cognitiveservices.azure.com/
storage:
connection-string: DefaultEndpointsProtocol=https;AccountName=testaccount;AccountKey=testkey;EndpointSuffix=core.windows.net
container-name: test-recordings
event-hubs:
connection-string: Endpoint=sb://test-eventhub.servicebus.windows.net/;SharedAccessKeyName=test;SharedAccessKey=test
consumer-group: test-group
# 로깅 설정
logging:
level:
com.unicorn.hgzero.stt: DEBUG
org.springframework.web: DEBUG
org.hibernate.SQL: DEBUG