mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 06:46:24 +00:00
백엔드 stt 서비스 개발
This commit is contained in:
parent
8c148e2721
commit
53f499cc7c
112
stt/bin/main/application.yml
Normal file
112
stt/bin/main/application.yml
Normal 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}
|
||||
29
stt/src/main/java/com/unicorn/hgzero/stt/SttApplication.java
Normal file
29
stt/src/main/java/com/unicorn/hgzero/stt/SttApplication.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
// }
|
||||
}
|
||||
@ -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", "서버 내부 오류가 발생했습니다"));
|
||||
}
|
||||
}
|
||||
@ -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("운영 서버")
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
72
stt/src/main/java/com/unicorn/hgzero/stt/domain/Speaker.java
Normal file
72
stt/src/main/java/com/unicorn/hgzero/stt/domain/Speaker.java
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
122
stt/src/main/java/com/unicorn/hgzero/stt/dto/RecordingDto.java
Normal file
122
stt/src/main/java/com/unicorn/hgzero/stt/dto/RecordingDto.java
Normal 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;
|
||||
}
|
||||
}
|
||||
109
stt/src/main/java/com/unicorn/hgzero/stt/dto/SpeakerDto.java
Normal file
109
stt/src/main/java/com/unicorn/hgzero/stt/dto/SpeakerDto.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
120
stt/src/main/java/com/unicorn/hgzero/stt/event/SpeakerEvent.java
Normal file
120
stt/src/main/java/com/unicorn/hgzero/stt/event/SpeakerEvent.java
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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. 화자 인식 모델 업데이트
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
*/
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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이 정상적으로 생성되고 주입되는지 검증
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
55
stt/src/test/resources/application-test.yml
Normal file
55
stt/src/test/resources/application-test.yml
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user