for merge

This commit is contained in:
djeon
2025-10-29 15:33:31 +09:00
92 changed files with 8906 additions and 5757 deletions
+56
View File
@@ -0,0 +1,56 @@
# Minutes Sections 테이블 에러 빠른 해결 가이드
## 🚨 발생한 에러
```
Caused by: org.postgresql.util.PSQLException:
ERROR: column "id" of relation "minutes_sections" contains null values
```
## ✅ 해결 방법 (2단계)
### 1단계: 데이터베이스 정리
IntelliJ에서 다음 중 하나를 실행:
**방법 A: 직접 SQL 실행**
```sql
DELETE FROM minutes_sections WHERE id IS NULL;
```
**방법 B: cleanup-minutes-sections.sql 파일 실행**
1. IntelliJ Database 탭 열기
2. `meetingdb` 우클릭 → New → Query Console
3. `cleanup-minutes-sections.sql` 파일 내용 복사 & 실행
### 2단계: Meeting 서비스 재시작
IntelliJ Run Configuration에서 Meeting 서비스 재시작
## 📝 수정된 파일
1. **test-data-minutes-sections.sql**
- Entity 구조에 맞게 컬럼명 수정
- `id` 컬럼 추가 (필수)
- `type`, `title`, `order` 등 추가
- `section_number`, `section_title` 제거
2. **cleanup-minutes-sections.sql**
- null id 레코드 삭제 스크립트
3. **README-FIX-MINUTES-SECTIONS.md**
- 상세 문제 해결 가이드
## 🔍 확인 사항
서비스 시작 후 로그 확인:
```bash
tail -f logs/meeting-service.log
```
에러가 없으면 성공! 다음 단계로 진행하세요.
## 📚 참고
- Entity: `MinutesSectionEntity.java`
- Repository: `MinutesSectionRepository.java` (필요시 생성)
- Service: `EndMeetingService.java`
+72
View File
@@ -0,0 +1,72 @@
# minutes_sections 테이블 에러 해결 가이드
## 문제 상황
Meeting 서비스 시작 시 다음 에러 발생:
```
Caused by: org.postgresql.util.PSQLException: ERROR: column "id" of relation "minutes_sections" contains null values
```
## 원인
- `minutes_sections` 테이블에 null id를 가진 레코드가 존재
- Hibernate가 id 컬럼을 NOT NULL PRIMARY KEY로 변경하려 시도
- 기존 null 데이터 때문에 ALTER TABLE 실패
## 해결 방법
### 방법 1: IntelliJ Database 도구 사용 (권장)
1. IntelliJ에서 Database 탭 열기
2. `meetingdb` 데이터베이스 연결
3. Query Console 열기
4. 다음 SQL 실행:
```sql
-- null id를 가진 레코드 삭제
DELETE FROM minutes_sections WHERE id IS NULL;
-- 결과 확인
SELECT COUNT(*) FROM minutes_sections;
```
### 방법 2: cleanup-minutes-sections.sql 파일 실행
IntelliJ Database Console에서 `cleanup-minutes-sections.sql` 파일을 열어서 실행
## 실행 후
1. Meeting 서비스 재시작
2. 로그에서 에러가 없는지 확인:
```bash
tail -f logs/meeting-service.log | grep -i error
```
3. 정상 시작되면 테스트 진행
## 추가 정보
### 테이블 구조 확인
```sql
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'minutes_sections'
ORDER BY ordinal_position;
```
### 현재 데이터 확인
```sql
SELECT id, minutes_id, type, title FROM minutes_sections LIMIT 10;
```
### Flyway 마이그레이션 이력 확인
```sql
SELECT * FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 5;
```
## 참고사항
- 이 에러는 기존 테이블에 데이터가 있는 상태에서 Entity 구조가 변경되어 발생
- 향후 같은 문제를 방지하려면 Flyway 마이그레이션 파일로 스키마 변경을 관리해야 함
- 테스트 데이터는 `test-data-minutes-sections.sql` 파일 참조
+18
View File
@@ -0,0 +1,18 @@
-- minutes 테이블 구조 확인
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'minutes'
ORDER BY ordinal_position;
-- Primary Key 확인
SELECT
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.table_name = 'minutes'
AND tc.constraint_type = 'PRIMARY KEY';
+40
View File
@@ -0,0 +1,40 @@
#!/bin/bash
# minutes_sections 테이블 정리 스크립트
# 목적: null id를 가진 레코드 삭제
echo "========================================="
echo "minutes_sections 테이블 정리 시작"
echo "========================================="
# PostgreSQL 연결 정보
DB_HOST="localhost"
DB_PORT="5432"
DB_NAME="meetingdb"
DB_USER="postgres"
# 1. 기존 데이터 확인
echo ""
echo "1. 현재 테이블 상태 확인..."
docker exec -i postgres-meeting psql -U $DB_USER -d $DB_NAME -c "SELECT COUNT(*) as total_rows FROM minutes_sections;"
docker exec -i postgres-meeting psql -U $DB_USER -d $DB_NAME -c "SELECT COUNT(*) as null_id_rows FROM minutes_sections WHERE id IS NULL;"
# 2. null id를 가진 레코드 삭제
echo ""
echo "2. null id를 가진 레코드 삭제..."
docker exec -i postgres-meeting psql -U $DB_USER -d $DB_NAME -c "DELETE FROM minutes_sections WHERE id IS NULL;"
# 3. 정리 완료 확인
echo ""
echo "3. 테이블 정리 완료. 현재 상태:"
docker exec -i postgres-meeting psql -U $DB_USER -d $DB_NAME -c "SELECT COUNT(*) as remaining_rows FROM minutes_sections;"
# 4. 테이블 구조 확인
echo ""
echo "4. 테이블 구조 확인:"
docker exec -i postgres-meeting psql -U $DB_USER -d $DB_NAME -c "\d minutes_sections"
echo ""
echo "========================================="
echo "정리 완료! Meeting 서비스를 재시작하세요."
echo "========================================="
+26
View File
@@ -0,0 +1,26 @@
-- ========================================
-- minutes_sections 테이블 정리 SQL
-- ========================================
-- 목적: null id를 가진 레코드 삭제하여 서비스 시작 가능하게 함
-- 실행방법: IntelliJ Database 도구에서 실행
-- 1. 현재 상태 확인
SELECT 'Total rows:' as info, COUNT(*) as count FROM minutes_sections
UNION ALL
SELECT 'Null ID rows:', COUNT(*) FROM minutes_sections WHERE id IS NULL;
-- 2. null id를 가진 레코드 삭제
DELETE FROM minutes_sections WHERE id IS NULL;
-- 3. 결과 확인
SELECT 'Remaining rows:' as info, COUNT(*) as count FROM minutes_sections;
-- 4. 테이블 구조 확인
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_name = 'minutes_sections'
ORDER BY ordinal_position;
+39
View File
@@ -0,0 +1,39 @@
-- 직접 실행: minutes_sections 테이블 재생성
-- 1. 기존 테이블 삭제
DROP TABLE IF EXISTS minutes_sections CASCADE;
-- 2. 테이블 재생성
CREATE TABLE minutes_sections (
id VARCHAR(50) PRIMARY KEY,
minutes_id VARCHAR(50) NOT NULL,
type VARCHAR(50),
title VARCHAR(200),
content TEXT,
"order" INTEGER,
verified BOOLEAN DEFAULT FALSE,
locked BOOLEAN DEFAULT FALSE,
locked_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_minutes_sections_minutes
FOREIGN KEY (minutes_id) REFERENCES minutes(id)
ON DELETE CASCADE
);
-- 3. 인덱스 생성
CREATE INDEX idx_minutes_sections_minutes ON minutes_sections(minutes_id);
CREATE INDEX idx_minutes_sections_order ON minutes_sections(minutes_id, "order");
CREATE INDEX idx_minutes_sections_type ON minutes_sections(type);
CREATE INDEX idx_minutes_sections_verified ON minutes_sections(verified);
-- 4. 트리거 생성
DROP TRIGGER IF EXISTS update_minutes_sections_updated_at ON minutes_sections;
CREATE TRIGGER update_minutes_sections_updated_at
BEFORE UPDATE ON minutes_sections
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- 확인
SELECT 'minutes_sections 테이블이 성공적으로 생성되었습니다!' as status;
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
nohup: ./gradlew: No such file or directory
-1
View File
@@ -1 +0,0 @@
nohup: ./gradlew: No such file or directory
+142
View File
@@ -0,0 +1,142 @@
-- ====================================================================
-- agenda_sections 테이블 마이그레이션 스크립트
--
-- 목적:
-- 1. agenda_number: varchar(50) → integer 변환
-- 2. decisions, pending_items, todos: text → json 변환
-- 3. opinions 컬럼 삭제 (서비스에서 미사용)
--
-- 실행 전 필수 작업:
-- 1. 데이터베이스 백업 (pg_dump)
-- 2. 테스트 환경에서 먼저 실행 및 검증
-- ====================================================================
-- 트랜잭션 시작
BEGIN;
-- ====================================================================
-- 1단계: 백업 테이블 생성 (롤백용)
-- ====================================================================
CREATE TABLE IF NOT EXISTS agenda_sections_backup AS
SELECT * FROM agenda_sections;
SELECT '✓ 백업 테이블 생성 완료: agenda_sections_backup' AS status;
-- ====================================================================
-- 2단계: agenda_number 컬럼 타입 변경 (varchar → integer)
-- ====================================================================
-- 데이터 검증: 숫자가 아닌 값이 있는지 확인
DO $$
DECLARE
invalid_count INTEGER;
BEGIN
SELECT COUNT(*) INTO invalid_count
FROM agenda_sections
WHERE agenda_number !~ '^[0-9]+$';
IF invalid_count > 0 THEN
RAISE EXCEPTION '숫자가 아닌 agenda_number 값이 % 건 발견됨. 데이터 정리 필요.', invalid_count;
END IF;
RAISE NOTICE '✓ agenda_number 데이터 검증 완료 (모두 숫자)';
END $$;
-- 타입 변경 실행
ALTER TABLE agenda_sections
ALTER COLUMN agenda_number TYPE integer
USING agenda_number::integer;
SELECT '✓ agenda_number 타입 변경 완료: varchar(50) → integer' AS status;
-- ====================================================================
-- 3단계: JSON 컬럼 타입 변경 (text → json)
-- ====================================================================
-- 3-1. decisions 컬럼 변경
ALTER TABLE agenda_sections
ALTER COLUMN decisions TYPE json
USING CASE
WHEN decisions IS NULL OR decisions = '' THEN NULL
ELSE decisions::json
END;
SELECT '✓ decisions 타입 변경 완료: text → json' AS status;
-- 3-2. pending_items 컬럼 변경
ALTER TABLE agenda_sections
ALTER COLUMN pending_items TYPE json
USING CASE
WHEN pending_items IS NULL OR pending_items = '' THEN NULL
ELSE pending_items::json
END;
SELECT '✓ pending_items 타입 변경 완료: text → json' AS status;
-- 3-3. todos 컬럼 변경
ALTER TABLE agenda_sections
ALTER COLUMN todos TYPE json
USING CASE
WHEN todos IS NULL OR todos = '' THEN NULL
ELSE todos::json
END;
SELECT '✓ todos 타입 변경 완료: text → json' AS status;
-- ====================================================================
-- 4단계: opinions 컬럼 삭제 (서비스에서 미사용)
-- ====================================================================
ALTER TABLE agenda_sections
DROP COLUMN IF EXISTS opinions;
SELECT '✓ opinions 컬럼 삭제 완료' AS status;
-- ====================================================================
-- 5단계: 변경 사항 검증
-- ====================================================================
DO $$
DECLARE
rec RECORD;
BEGIN
-- 테이블 구조 확인
SELECT
column_name,
data_type,
character_maximum_length,
is_nullable
INTO rec
FROM information_schema.columns
WHERE table_name = 'agenda_sections'
AND column_name = 'agenda_number';
RAISE NOTICE '========================================';
RAISE NOTICE '✓ 마이그레이션 검증 결과';
RAISE NOTICE '========================================';
RAISE NOTICE 'agenda_number 타입: %', rec.data_type;
-- 데이터 건수 확인
RAISE NOTICE '원본 데이터 건수: %', (SELECT COUNT(*) FROM agenda_sections_backup);
RAISE NOTICE '마이그레이션 후 건수: %', (SELECT COUNT(*) FROM agenda_sections);
RAISE NOTICE '========================================';
END $$;
-- ====================================================================
-- 커밋 또는 롤백 선택
-- ====================================================================
-- 문제가 없으면 COMMIT, 문제가 있으면 ROLLBACK 실행
-- 성공 시: COMMIT;
-- 실패 시: ROLLBACK;
COMMIT;
SELECT '
====================================================================
✓ 마이그레이션 완료!
다음 작업:
1. 애플리케이션 재시작
2. 기능 테스트 수행
3. 문제 없으면 백업 테이블 삭제:
DROP TABLE agenda_sections_backup;
====================================================================
' AS next_steps;
@@ -37,6 +37,11 @@ public class MeetingAnalysis {
*/
private List<String> keywords;
/**
* 회의 전체 결정사항
*/
private String decisions;
/**
* 안건별 분석 결과
*/
@@ -31,6 +31,11 @@ public class Minutes {
*/
private String meetingId;
/**
* 작성자 사용자 ID (NULL: AI 통합 회의록, NOT NULL: 참석자별 회의록)
*/
private String userId;
/**
* 회의록 제목
*/
@@ -71,6 +76,11 @@ public class Minutes {
*/
private String lastModifiedBy;
/**
* 회의 전체 결정사항
*/
private String decisions;
/**
* 확정자 ID
*/
@@ -106,6 +106,11 @@ public class MinutesDTO {
*/
private final LocalDateTime lastModifiedAt;
/**
* 회의 전체 결정사항
*/
private final String decisions;
/**
* Todo 개수
*/
@@ -0,0 +1,267 @@
package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis;
import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO;
import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.EndMeetingUseCase;
import com.unicorn.hgzero.meeting.infra.client.AIServiceClient;
import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateRequest;
import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateResponse;
import com.unicorn.hgzero.meeting.infra.dto.ai.ExtractedTodoDTO;
import com.unicorn.hgzero.meeting.infra.dto.ai.ParticipantMinutesDTO;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingAnalysisEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MinutesSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.entity.TodoEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingAnalysisJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesSectionJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.TodoJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
/**
* 회의 종료 비즈니스 로직 (AI 통합)
*/
@Slf4j
@Service
@Primary
@RequiredArgsConstructor
public class EndMeetingService implements EndMeetingUseCase {
private final MeetingJpaRepository meetingRepository;
private final MinutesJpaRepository minutesRepository;
private final MinutesSectionJpaRepository minutesSectionRepository;
private final TodoJpaRepository todoRepository;
private final MeetingAnalysisJpaRepository analysisRepository;
private final AIServiceClient aiServiceClient;
/**
* 회의 종료 및 AI 분석 실행
*
* @param meetingId 회의 ID
* @return 회의 종료 결과 DTO
*/
@Override
@Transactional
public MeetingEndDTO endMeeting(String meetingId) {
log.info("회의 종료 시작 - meetingId: {}", meetingId);
// 1. 회의 정보 조회
MeetingEntity meeting = meetingRepository.findById(meetingId)
.orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + meetingId));
// 2. 참석자별 회의록 조회 (userId가 있는 회의록들)
List<MinutesEntity> participantMinutesList = minutesRepository.findByMeetingIdAndUserIdIsNotNull(meetingId);
if (participantMinutesList.isEmpty()) {
throw new IllegalStateException("참석자 회의록이 없습니다: " + meetingId);
}
// 3. 각 회의록의 sections 조회 및 통합
List<MinutesSectionEntity> allMinutesSections = new ArrayList<>();
for (MinutesEntity minutes : participantMinutesList) {
List<MinutesSectionEntity> sections = minutesSectionRepository.findByMinutesIdOrderByOrderAsc(
minutes.getMinutesId()
);
allMinutesSections.addAll(sections);
}
// 4. AI 통합 분석 요청 데이터 생성
ConsolidateRequest request = createConsolidateRequest(meeting, allMinutesSections, participantMinutesList);
// 5. AI Service 호출
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
// 5. AI 분석 결과 저장
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
// 6. Todo 생성 및 저장
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
// 6. 회의 종료 처리
meeting.end();
meetingRepository.save(meeting);
// 7. 응답 DTO 생성
return createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
}
/**
* AI 통합 분석 요청 데이터 생성
* 참석자별 회의록의 섹션들을 참석자별로 그룹화하여 AI 요청 데이터 생성
*/
private ConsolidateRequest createConsolidateRequest(
MeetingEntity meeting,
List<MinutesSectionEntity> allMinutesSections,
List<MinutesEntity> participantMinutesList) {
// 참석자별 회의록을 ParticipantMinutesDTO로 변환
List<ParticipantMinutesDTO> participantMinutes = participantMinutesList.stream()
.<ParticipantMinutesDTO>map(minutes -> {
// 해당 회의록의 섹션들만 필터링
String content = allMinutesSections.stream()
.filter(section -> section.getMinutesId().equals(minutes.getMinutesId()))
.<String>map(section -> section.getTitle() + "\n" + section.getContent())
.collect(Collectors.joining("\n\n"));
return ParticipantMinutesDTO.builder()
.userId(minutes.getUserId())
.userName(minutes.getUserId()) // 실제로는 userName이 필요하지만 일단 userId 사용
.content(content)
.build();
})
.collect(Collectors.toList());
return ConsolidateRequest.builder()
.meetingId(meeting.getMeetingId())
.participantMinutes(participantMinutes)
.build();
}
/**
* AI 분석 결과 저장
*/
private MeetingAnalysis saveAnalysisResult(MeetingEntity meeting, ConsolidateResponse aiResponse) {
// AgendaAnalysis 리스트 생성
List<MeetingAnalysis.AgendaAnalysis> agendaAnalyses = aiResponse.getAgendaSummaries().stream()
.<MeetingAnalysis.AgendaAnalysis>map(summary -> MeetingAnalysis.AgendaAnalysis.builder()
.agendaId(UUID.randomUUID().toString())
.title(summary.getAgendaTitle())
.aiSummaryShort(summary.getSummaryShort())
.discussion(summary.getSummary() != null ? summary.getSummary() : "")
.decisions(List.of())
.pending(summary.getPending() != null ? summary.getPending() : List.of())
.extractedTodos(summary.getTodos() != null
? summary.getTodos().stream()
.<String>map(todo -> todo.getTitle())
.collect(Collectors.toList())
: List.of())
.build())
.collect(Collectors.toList());
// MeetingAnalysis 도메인 생성
MeetingAnalysis analysis = MeetingAnalysis.builder()
.analysisId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요
.keywords(aiResponse.getKeywords())
.decisions(aiResponse.getDecisions())
.agendaAnalyses(agendaAnalyses)
.status("COMPLETED")
.completedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.build();
// Entity 저장
MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis);
analysisRepository.save(entity);
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
return analysis;
}
/**
* Todo 생성 및 저장
*/
private List<TodoEntity> createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) {
List<TodoEntity> todos = aiResponse.getAgendaSummaries().stream()
.<TodoEntity>flatMap(agenda -> {
String agendaId = findAgendaIdByTitle(analysis, agenda.getAgendaTitle());
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
return todoList.stream()
.<TodoEntity>map(todo -> TodoEntity.builder()
.todoId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요
.title(todo.getTitle())
.assigneeId("") // AI가 담당자를 추출하지 않으므로 빈 값
.status("PENDING")
.build());
})
.collect(Collectors.toList());
if (!todos.isEmpty()) {
todoRepository.saveAll(todos);
log.info("Todo 생성 완료 - 총 {}개", todos.size());
}
return todos;
}
/**
* 안건 제목으로 안건 ID 찾기
*/
private String findAgendaIdByTitle(MeetingAnalysis analysis, String title) {
return analysis.getAgendaAnalyses().stream()
.filter(agenda -> agenda.getTitle().equals(title))
.findFirst()
.map(MeetingAnalysis.AgendaAnalysis::getAgendaId)
.orElse(null);
}
/**
* 회의 종료 결과 DTO 생성
*/
private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, MeetingAnalysis analysis,
List<TodoEntity> todos, int participantCount) {
// 회의 소요 시간 계산
int durationMinutes = calculateDurationMinutes(meeting.getStartedAt(), meeting.getEndedAt());
// 안건별 요약 DTO 생성
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = analysis.getAgendaAnalyses().stream()
.<MeetingEndDTO.AgendaSummaryDTO>map(agenda -> {
// 해당 안건의 Todo 필터링 (agendaId가 없을 수 있음)
List<MeetingEndDTO.TodoSummaryDTO> agendaTodos = todos.stream()
.filter(todo -> agenda.getAgendaId().equals(todo.getMinutesId())) // 임시 매핑
.<MeetingEndDTO.TodoSummaryDTO>map(todo -> MeetingEndDTO.TodoSummaryDTO.builder()
.title(todo.getTitle())
.build())
.collect(Collectors.toList());
return MeetingEndDTO.AgendaSummaryDTO.builder()
.title(agenda.getTitle())
.aiSummaryShort(agenda.getAiSummaryShort())
.details(MeetingEndDTO.AgendaDetailsDTO.builder()
.discussion(agenda.getDiscussion())
.decisions(agenda.getDecisions())
.pending(agenda.getPending())
.build())
.todos(agendaTodos)
.build();
})
.collect(Collectors.toList());
return MeetingEndDTO.builder()
.title(meeting.getTitle())
.participantCount(participantCount)
.durationMinutes(durationMinutes)
.agendaCount(analysis.getAgendaAnalyses().size())
.todoCount(todos.size())
.keywords(analysis.getKeywords())
.agendaSummaries(agendaSummaries)
.build();
}
/**
* 회의 소요 시간 계산 (분 단위)
*/
private int calculateDurationMinutes(LocalDateTime startedAt, LocalDateTime endedAt) {
if (startedAt == null || endedAt == null) {
return 0;
}
return (int) Duration.between(startedAt, endedAt).toMinutes();
}
}
@@ -389,4 +389,30 @@ public class MinutesService implements
.sections(sectionDTOs) // 섹션 정보 추가
.build();
}
/**
* 회의 ID로 참석자별 회의록 조회
* AI Service가 통합 회의록 생성 시 사용
*
* @param meetingId 회의 ID
* @return 참석자별 회의록 목록 (user_id IS NOT NULL)
*/
@Transactional(readOnly = true)
public List<Minutes> getParticipantMinutesByMeeting(String meetingId) {
log.info("회의 ID로 참석자별 회의록 조회: {}", meetingId);
return minutesReader.findParticipantMinutesByMeetingId(meetingId);
}
/**
* 회의 ID로 AI 통합 회의록 조회
*
* @param meetingId 회의 ID
* @return AI 통합 회의록 (user_id IS NULL)
*/
@Transactional(readOnly = true)
public Minutes getConsolidatedMinutesByMeeting(String meetingId) {
log.info("회의 ID로 AI 통합 회의록 조회: {}", meetingId);
return minutesReader.findConsolidatedMinutesByMeetingId(meetingId)
.orElse(null);
}
}
@@ -0,0 +1,45 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import java.util.List;
import java.util.Optional;
/**
* 안건별 회의록 섹션 조회 인터페이스
*/
public interface AgendaSectionReader {
/**
* 회의 ID로 안건별 섹션 조회
*
* @param meetingId 회의 ID
* @return 안건별 섹션 목록 (안건 번호 순 정렬)
*/
List<AgendaSection> findByMeetingId(String meetingId);
/**
* 회의록 ID로 안건별 섹션 조회
*
* @param minutesId 회의록 ID
* @return 안건별 섹션 목록
*/
List<AgendaSection> findByMinutesId(String minutesId);
/**
* 섹션 ID로 조회
*
* @param id 섹션 ID
* @return 안건 섹션
*/
Optional<AgendaSection> findById(String id);
/**
* 회의 ID와 안건 번호로 섹션 조회
*
* @param meetingId 회의 ID
* @param agendaNumber 안건 번호
* @return 안건 섹션
*/
Optional<AgendaSection> findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber);
}
@@ -0,0 +1,41 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import java.util.List;
/**
* 안건별 회의록 섹션 저장 인터페이스
*/
public interface AgendaSectionWriter {
/**
* 안건별 섹션 저장
*
* @param section 안건 섹션
* @return 저장된 안건 섹션
*/
AgendaSection save(AgendaSection section);
/**
* 안건별 섹션 일괄 저장
*
* @param sections 안건 섹션 목록
* @return 저장된 안건 섹션 목록
*/
List<AgendaSection> saveAll(List<AgendaSection> sections);
/**
* 안건별 섹션 삭제
*
* @param id 섹션 ID
*/
void delete(String id);
/**
* 회의 ID로 안건별 섹션 전체 삭제
*
* @param meetingId 회의 ID
*/
void deleteByMeetingId(String meetingId);
}
@@ -39,4 +39,21 @@ public interface MinutesReader {
* 확정자 ID로 회의록 목록 조회
*/
List<Minutes> findByFinalizedBy(String finalizedBy);
/**
* 회의 ID로 참석자별 회의록 조회 (user_id IS NOT NULL)
* AI Service가 통합 회의록 생성 시 사용
*
* @param meetingId 회의 ID
* @return 참석자별 회의록 목록
*/
List<Minutes> findParticipantMinutesByMeetingId(String meetingId);
/**
* 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL)
*
* @param meetingId 회의 ID
* @return AI 통합 회의록
*/
Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId);
}
@@ -0,0 +1,80 @@
package com.unicorn.hgzero.meeting.infra.client;
import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateRequest;
import com.unicorn.hgzero.meeting.infra.dto.ai.ConsolidateResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import java.time.Duration;
/**
* AI Service 호출 클라이언트
*/
@Slf4j
@Component
public class AIServiceClient {
private final RestTemplate restTemplate;
private final String aiServiceUrl;
public AIServiceClient(
RestTemplateBuilder restTemplateBuilder,
@Value("${ai.service.url:http://localhost:8087}") String aiServiceUrl,
@Value("${ai.service.timeout:30000}") int timeout
) {
this.restTemplate = restTemplateBuilder
.setConnectTimeout(Duration.ofMillis(timeout))
.setReadTimeout(Duration.ofMillis(timeout))
.build();
this.aiServiceUrl = aiServiceUrl;
}
/**
* 회의록 통합 요약 API 호출
*
* @param request 통합 요약 요청
* @return 통합 요약 응답
*/
public ConsolidateResponse consolidateMinutes(ConsolidateRequest request) {
log.info("AI Service 호출 - 회의록 통합 요약: {}", request.getMeetingId());
try {
String url = aiServiceUrl + "/api/transcripts/consolidate";
// HTTP 헤더 설정
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
// HTTP 요청 생성
HttpEntity<ConsolidateRequest> httpEntity = new HttpEntity<>(request, headers);
// API 호출
ResponseEntity<ConsolidateResponse> response = restTemplate.postForEntity(
url,
httpEntity,
ConsolidateResponse.class
);
ConsolidateResponse result = response.getBody();
if (result == null) {
throw new RuntimeException("AI Service 응답이 비어있습니다");
}
log.info("AI Service 응답 수신 완료 - 안건 수: {}", result.getAgendaSummaries().size());
return result;
} catch (Exception e) {
log.error("AI Service 호출 실패: {}", e.getMessage(), e);
throw new RuntimeException("AI 회의록 통합 처리 중 오류가 발생했습니다: " + e.getMessage(), e);
}
}
}
@@ -0,0 +1,261 @@
package com.unicorn.hgzero.meeting.infra.controller;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.service.AgendaSectionService;
import com.unicorn.hgzero.meeting.biz.service.MeetingService;
import com.unicorn.hgzero.meeting.biz.service.MinutesService;
import com.unicorn.hgzero.meeting.infra.dto.response.ParticipantMinutesResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.AgendaSectionResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MeetingStatisticsResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 회의 AI 기능 API Controller
* 회의 종료 후 AI 통합 회의록 생성을 위한 API
*/
@RestController
@RequestMapping("/api/meetings")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Meeting AI", description = "회의 AI 통합 기능 API")
public class MeetingAiController {
private final MinutesService minutesService;
private final AgendaSectionService agendaSectionService;
private final MeetingService meetingService;
private final ObjectMapper objectMapper;
@GetMapping("/{meetingId}/ai/participant-minutes")
@Operation(
summary = "참석자별 회의록 조회",
description = "특정 회의의 모든 참석자가 작성한 회의록을 조회합니다. AI Service가 통합 회의록 생성 시 사용합니다."
)
public ResponseEntity<ApiResponse<ParticipantMinutesResponse>> getParticipantMinutes(
@Parameter(description = "회의 ID") @PathVariable String meetingId,
@RequestHeader("X-User-Id") String userId) {
log.info("참석자별 회의록 조회 요청 - meetingId: {}, userId: {}", meetingId, userId);
try {
List<Minutes> participantMinutes = minutesService.getParticipantMinutesByMeeting(meetingId);
List<ParticipantMinutesResponse.ParticipantMinutesItem> items = participantMinutes.stream()
.map(this::convertToParticipantMinutesItem)
.collect(Collectors.toList());
ParticipantMinutesResponse response = ParticipantMinutesResponse.builder()
.meetingId(meetingId)
.participantMinutes(items)
.build();
log.info("참석자별 회의록 조회 성공 - meetingId: {}, count: {}", meetingId, items.size());
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("참석자별 회의록 조회 실패 - meetingId: {}", meetingId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("참석자별 회의록 조회에 실패했습니다"));
}
}
@GetMapping("/{meetingId}/ai/agenda-sections")
@Operation(
summary = "안건별 섹션 조회",
description = "특정 회의의 안건별 AI 요약 섹션을 조회합니다. 회의 종료 화면에서 사용합니다."
)
public ResponseEntity<ApiResponse<AgendaSectionResponse>> getAgendaSections(
@Parameter(description = "회의 ID") @PathVariable String meetingId,
@RequestHeader("X-User-Id") String userId) {
log.info("안건별 섹션 조회 요청 - meetingId: {}, userId: {}", meetingId, userId);
try {
List<AgendaSection> sections = agendaSectionService.getAgendaSectionsByMeetingId(meetingId);
List<AgendaSectionResponse.AgendaSectionItem> items = sections.stream()
.map(this::convertToAgendaSectionItem)
.collect(Collectors.toList());
AgendaSectionResponse response = AgendaSectionResponse.builder()
.meetingId(meetingId)
.sections(items)
.build();
log.info("안건별 섹션 조회 성공 - meetingId: {}, count: {}", meetingId, items.size());
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("안건별 섹션 조회 실패 - meetingId: {}", meetingId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("안건별 섹션 조회에 실패했습니다"));
}
}
@GetMapping("/{meetingId}/ai/statistics")
@Operation(
summary = "회의 통계 조회",
description = "특정 회의의 통계 정보를 조회합니다. 회의 종료 화면에서 사용합니다."
)
public ResponseEntity<ApiResponse<MeetingStatisticsResponse>> getMeetingStatistics(
@Parameter(description = "회의 ID") @PathVariable String meetingId,
@RequestHeader("X-User-Id") String userId) {
log.info("회의 통계 조회 요청 - meetingId: {}, userId: {}", meetingId, userId);
try {
MeetingStatisticsResponse response = buildMeetingStatistics(meetingId);
log.info("회의 통계 조회 성공 - meetingId: {}", meetingId);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("회의 통계 조회 실패 - meetingId: {}", meetingId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("회의 통계 조회에 실패했습니다"));
}
}
private ParticipantMinutesResponse.ParticipantMinutesItem convertToParticipantMinutesItem(Minutes minutes) {
List<ParticipantMinutesResponse.ParticipantMinutesItem.MinutesSection> sections = null;
if (minutes.getSections() != null) {
sections = minutes.getSections().stream()
.map(this::convertToMinutesSection)
.collect(Collectors.toList());
}
return ParticipantMinutesResponse.ParticipantMinutesItem.builder()
.minutesId(minutes.getMinutesId())
.userId(minutes.getUserId())
.title(minutes.getTitle())
.status(minutes.getStatus())
.version(minutes.getVersion())
.createdBy(minutes.getCreatedBy())
.createdAt(minutes.getCreatedAt())
.lastModifiedAt(minutes.getLastModifiedAt())
.sections(sections)
.build();
}
private ParticipantMinutesResponse.ParticipantMinutesItem.MinutesSection convertToMinutesSection(MinutesSection section) {
return ParticipantMinutesResponse.ParticipantMinutesItem.MinutesSection.builder()
.sectionId(section.getSectionId())
.title(section.getTitle())
.type(section.getType())
.content(section.getContent())
.orderIndex(section.getOrder())
.build();
}
private AgendaSectionResponse.AgendaSectionItem convertToAgendaSectionItem(AgendaSection section) {
// todos JSON 파싱
List<AgendaSectionResponse.AgendaSectionItem.TodoItem> todos = parseTodosJson(section.getTodos());
// pendingItems는 사용하지 않으므로 null 처리
List<String> pendingItems = null;
return AgendaSectionResponse.AgendaSectionItem.builder()
.id(section.getId())
.minutesId(section.getMinutesId())
.agendaNumber(section.getAgendaNumber())
.agendaTitle(section.getAgendaTitle())
.aiSummaryShort(section.getAiSummaryShort())
.summary(section.getAiSummaryShort()) // summary 필드가 없으므로 aiSummaryShort 사용
.pendingItems(pendingItems)
.todos(todos)
.createdAt(section.getCreatedAt())
.updatedAt(section.getUpdatedAt())
.build();
}
/**
* todos JSON 문자열을 파싱하여 TodoItem 리스트로 변환
* JSON 구조: [{"title": "...", "assignee": "...", "dueDate": "...", "description": "...", "priority": "..."}]
*/
private List<AgendaSectionResponse.AgendaSectionItem.TodoItem> parseTodosJson(String todosJson) {
if (todosJson == null || todosJson.trim().isEmpty()) {
return new ArrayList<>();
}
try {
List<TodoJson> todoJsonList = objectMapper.readValue(
todosJson,
new TypeReference<List<TodoJson>>() {}
);
return todoJsonList.stream()
.map(json -> AgendaSectionResponse.AgendaSectionItem.TodoItem.builder()
.title(json.getTitle())
.assignee(json.getAssignee())
.dueDate(json.getDueDate())
.description(json.getDescription())
.priority(json.getPriority())
.build())
.collect(Collectors.toList());
} catch (Exception e) {
log.error("Failed to parse todos JSON: {}", todosJson, e);
return new ArrayList<>();
}
}
/**
* JSON 파싱을 위한 임시 DTO
*/
@lombok.Data
private static class TodoJson {
private String title;
private String assignee;
private String dueDate;
private String description;
private String priority;
}
private MeetingStatisticsResponse buildMeetingStatistics(String meetingId) {
Meeting meeting = meetingService.getMeeting(meetingId);
List<AgendaSection> sections = agendaSectionService.getAgendaSectionsByMeetingId(meetingId);
// AI가 추출한 Todo 수 계산
int todoCount = sections.stream()
.mapToInt(s -> parseTodosJson(s.getTodos()).size())
.sum();
// 회의 시간 계산
Integer durationMinutes = null;
if (meeting.getStartedAt() != null && meeting.getEndedAt() != null) {
durationMinutes = (int) java.time.Duration.between(
meeting.getStartedAt(),
meeting.getEndedAt()
).toMinutes();
}
return MeetingStatisticsResponse.builder()
.meetingId(meetingId)
.meetingTitle(meeting.getTitle())
.startTime(meeting.getStartedAt())
.endTime(meeting.getEndedAt())
.durationMinutes(durationMinutes)
.participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0)
.agendaCount(sections.size())
.todoCount(todoCount)
.build();
}
}
@@ -0,0 +1,53 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 안건별 요약 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgendaSummaryDTO {
/**
* 안건 번호
*/
@JsonProperty("agenda_number")
private Integer agendaNumber;
/**
* 안건 제목
*/
@JsonProperty("agenda_title")
private String agendaTitle;
/**
* 짧은 요약 (1줄)
*/
@JsonProperty("summary_short")
private String summaryShort;
/**
* 안건별 회의록 요약
* 사용자가 입력한 회의록 내용을 요약한 결과
*/
private String summary;
/**
* 보류 사항
*/
private List<String> pending;
/**
* Todo 목록
*/
private List<ExtractedTodoDTO> todos;
}
@@ -0,0 +1,42 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* AI Service - 회의록 통합 요약 요청 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConsolidateRequest {
/**
* 회의 ID
*/
@JsonProperty("meeting_id")
private String meetingId;
/**
* 참석자별 회의록 목록
*/
@JsonProperty("participant_minutes")
private List<ParticipantMinutesDTO> participantMinutes;
/**
* 안건 목록 (선택)
*/
private List<String> agendas;
/**
* 회의 시간(분) (선택)
*/
@JsonProperty("duration_minutes")
private Integer durationMinutes;
}
@@ -0,0 +1,58 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* AI Service - 회의록 통합 요약 응답 DTO
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConsolidateResponse {
/**
* 회의 ID
*/
@JsonProperty("meeting_id")
private String meetingId;
/**
* 주요 키워드
*/
private List<String> keywords;
/**
* 통계 정보
* - participants_count: 참석자 수
* - agendas_count: 안건 수
* - todos_count: Todo 개수
* - duration_minutes: 회의 시간(분)
*/
private Map<String, Integer> statistics;
/**
* 회의 전체 결정사항
*/
private String decisions;
/**
* 안건별 요약
*/
@JsonProperty("agenda_summaries")
private List<AgendaSummaryDTO> agendaSummaries;
/**
* 생성 시각
*/
@JsonProperty("generated_at")
private LocalDateTime generatedAt;
}
@@ -0,0 +1,21 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* AI 추출 Todo DTO (제목만)
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExtractedTodoDTO {
/**
* Todo 제목
*/
private String title;
}
@@ -0,0 +1,34 @@
package com.unicorn.hgzero.meeting.infra.dto.ai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 참석자별 회의록 DTO (AI Service 요청용)
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantMinutesDTO {
/**
* 사용자 ID
*/
@JsonProperty("user_id")
private String userId;
/**
* 사용자 이름
*/
@JsonProperty("user_name")
private String userName;
/**
* 회의록 전체 내용 (MEMO 섹션)
*/
private String content;
}
@@ -0,0 +1,92 @@
package com.unicorn.hgzero.meeting.infra.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 안건별 섹션 조회 응답 DTO
* 회의 종료 화면에서 사용
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "안건별 섹션 조회 응답")
public class AgendaSectionResponse {
@Schema(description = "회의 ID", example = "MTG-2025-001")
private String meetingId;
@Schema(description = "안건별 섹션 목록")
private List<AgendaSectionItem> sections;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "안건별 섹션 항목")
public static class AgendaSectionItem {
@Schema(description = "섹션 ID", example = "AGENDA-SEC-001")
private String id;
@Schema(description = "회의록 ID", example = "MIN-CONSOLIDATED-001")
private String minutesId;
@Schema(description = "안건 번호", example = "1")
private Integer agendaNumber;
@Schema(description = "안건 제목", example = "Q1 마케팅 전략 수립")
private String agendaTitle;
@Schema(description = "AI 생성 짧은 요약", example = "타겟 고객을 20-30대로 설정")
private String aiSummaryShort;
@Schema(description = "안건별 회의록 요약", example = "신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고...")
private String summary;
@Schema(description = "보류 사항 목록")
private List<String> pendingItems;
@Schema(description = "AI 추출 Todo 목록")
private List<TodoItem> todos;
@Schema(description = "생성 시간", example = "2025-01-20T16:00:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
@Schema(description = "수정 시간", example = "2025-01-20T16:30:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime updatedAt;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Todo 항목")
public static class TodoItem {
@Schema(description = "Todo 제목", example = "시장 조사 보고서 작성")
private String title;
@Schema(description = "담당자", example = "김민준")
private String assignee;
@Schema(description = "마감일", example = "2025-02-15")
private String dueDate;
@Schema(description = "설명", example = "20-30대 타겟 시장 조사")
private String description;
@Schema(description = "우선순위", example = "HIGH", allowableValues = {"HIGH", "MEDIUM", "LOW"})
private String priority;
}
}
}
@@ -0,0 +1,48 @@
package com.unicorn.hgzero.meeting.infra.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 회의 통계 조회 응답 DTO
* 회의 종료 화면에서 통계 정보 표시
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회의 통계 조회 응답")
public class MeetingStatisticsResponse {
@Schema(description = "회의 ID", example = "MTG-2025-001")
private String meetingId;
@Schema(description = "회의 제목", example = "Q1 마케팅 전략 회의")
private String meetingTitle;
@Schema(description = "회의 시작 시간", example = "2025-01-20T14:00:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime startTime;
@Schema(description = "회의 종료 시간", example = "2025-01-20T16:00:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime endTime;
@Schema(description = "회의 총 시간 (분)", example = "120")
private Integer durationMinutes;
@Schema(description = "참석자 수", example = "5")
private Integer participantCount;
@Schema(description = "안건 수", example = "3")
private Integer agendaCount;
@Schema(description = "Todo 수", example = "12")
private Integer todoCount;
}
@@ -0,0 +1,89 @@
package com.unicorn.hgzero.meeting.infra.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 참석자별 회의록 조회 응답 DTO
* AI Service가 통합 회의록 생성 시 사용
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "참석자별 회의록 조회 응답")
public class ParticipantMinutesResponse {
@Schema(description = "회의 ID", example = "MTG-2025-001")
private String meetingId;
@Schema(description = "참석자별 회의록 목록")
private List<ParticipantMinutesItem> participantMinutes;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "참석자별 회의록 항목")
public static class ParticipantMinutesItem {
@Schema(description = "회의록 ID", example = "MIN-001")
private String minutesId;
@Schema(description = "작성자 사용자 ID", example = "user@example.com")
private String userId;
@Schema(description = "회의록 제목", example = "Q1 마케팅 전략 회의 - 김민준 작성")
private String title;
@Schema(description = "회의록 상태", example = "FINALIZED", allowableValues = {"DRAFT", "FINALIZED"})
private String status;
@Schema(description = "버전", example = "1")
private Integer version;
@Schema(description = "작성자 ID", example = "user@example.com")
private String createdBy;
@Schema(description = "생성 시간", example = "2025-01-20T14:30:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
@Schema(description = "최종 수정 시간", example = "2025-01-20T15:30:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime lastModifiedAt;
@Schema(description = "회의록 섹션 목록")
private List<MinutesSection> sections;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회의록 섹션")
public static class MinutesSection {
@Schema(description = "섹션 ID", example = "SEC-001")
private String sectionId;
@Schema(description = "섹션 제목", example = "주요 논의 사항")
private String title;
@Schema(description = "섹션 유형", example = "DISCUSSION", allowableValues = {"DISCUSSION", "DECISION", "ACTION_ITEM", "NOTE"})
private String type;
@Schema(description = "섹션 내용", example = "Q1 마케팅 캠페인 방향성에 대한 논의가 진행되었습니다...")
private String content;
@Schema(description = "섹션 순서", example = "1")
private Integer orderIndex;
}
}
}
@@ -0,0 +1,94 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.AgendaSectionJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 안건별 회의록 섹션 Gateway 구현체
*/
@Component
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class AgendaSectionGateway implements AgendaSectionReader, AgendaSectionWriter {
private final AgendaSectionJpaRepository repository;
@Override
public List<AgendaSection> findByMeetingId(String meetingId) {
log.debug("회의 ID로 안건별 섹션 조회: {}", meetingId);
return repository.findByMeetingIdOrderByAgendaNumberAsc(meetingId).stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public List<AgendaSection> findByMinutesId(String minutesId) {
log.debug("회의록 ID로 안건별 섹션 조회: {}", minutesId);
return repository.findByMinutesIdOrderByAgendaNumberAsc(minutesId).stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<AgendaSection> findById(String id) {
log.debug("ID로 안건별 섹션 조회: {}", id);
return repository.findById(id)
.map(AgendaSectionEntity::toDomain);
}
@Override
public Optional<AgendaSection> findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber) {
log.debug("회의 ID와 안건 번호로 섹션 조회: meetingId={}, agendaNumber={}", meetingId, agendaNumber);
return Optional.ofNullable(repository.findByMeetingIdAndAgendaNumber(meetingId, agendaNumber))
.map(AgendaSectionEntity::toDomain);
}
@Override
@Transactional
public AgendaSection save(AgendaSection section) {
log.debug("안건별 섹션 저장: {}", section.getId());
AgendaSectionEntity entity = AgendaSectionEntity.fromDomain(section);
AgendaSectionEntity saved = repository.save(entity);
return saved.toDomain();
}
@Override
@Transactional
public List<AgendaSection> saveAll(List<AgendaSection> sections) {
log.debug("안건별 섹션 일괄 저장: {} 개", sections.size());
List<AgendaSectionEntity> entities = sections.stream()
.map(AgendaSectionEntity::fromDomain)
.collect(Collectors.toList());
List<AgendaSectionEntity> saved = repository.saveAll(entities);
return saved.stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
@Transactional
public void delete(String id) {
log.debug("안건별 섹션 삭제: {}", id);
repository.deleteById(id);
}
@Override
@Transactional
public void deleteByMeetingId(String meetingId) {
log.debug("회의 ID로 안건별 섹션 전체 삭제: {}", meetingId);
repository.deleteByMeetingId(meetingId);
}
}
@@ -64,6 +64,21 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
.collect(Collectors.toList());
}
@Override
public List<Minutes> findParticipantMinutesByMeetingId(String meetingId) {
log.debug("회의 ID로 참석자별 회의록 조회: {}", meetingId);
return minutesJpaRepository.findByMeetingIdAndUserIdIsNotNull(meetingId).stream()
.map(MinutesEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId) {
log.debug("회의 ID로 AI 통합 회의록 조회: {}", meetingId);
return minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId)
.map(MinutesEntity::toDomain);
}
@Override
public Minutes save(Minutes minutes) {
// 기존 엔티티 조회 (update) 또는 새로 생성 (insert)
@@ -38,6 +38,9 @@ public class MeetingAnalysisEntity {
@Column(name = "keyword")
private List<String> keywords;
@Column(name = "decisions", columnDefinition = "TEXT")
private String decisions;
@Column(name = "agenda_analyses", columnDefinition = "TEXT")
private String agendaAnalysesJson; // JSON 문자열로 저장
@@ -55,12 +58,13 @@ public class MeetingAnalysisEntity {
*/
public MeetingAnalysis toDomain() {
List<MeetingAnalysis.AgendaAnalysis> agendaAnalyses = parseAgendaAnalyses();
return MeetingAnalysis.builder()
.analysisId(this.analysisId)
.meetingId(this.meetingId)
.minutesId(this.minutesId)
.keywords(this.keywords)
.decisions(this.decisions)
.agendaAnalyses(agendaAnalyses)
.status(this.status)
.completedAt(this.completedAt)
@@ -90,12 +94,13 @@ public class MeetingAnalysisEntity {
*/
public static MeetingAnalysisEntity fromDomain(MeetingAnalysis domain) {
String agendaAnalysesJson = convertAgendaAnalysesToJson(domain.getAgendaAnalyses());
return MeetingAnalysisEntity.builder()
.analysisId(domain.getAnalysisId())
.meetingId(domain.getMeetingId())
.minutesId(domain.getMinutesId())
.keywords(domain.getKeywords())
.decisions(domain.getDecisions())
.agendaAnalysesJson(agendaAnalysesJson)
.status(domain.getStatus())
.completedAt(domain.getCompletedAt())
@@ -31,13 +31,12 @@ public class MinutesEntity extends BaseTimeEntity {
@Column(name = "meeting_id", length = 50, nullable = false)
private String meetingId;
@Column(name = "user_id", length = 100)
private String userId;
@Column(name = "title", length = 200, nullable = false)
private String title;
@OneToMany(mappedBy = "minutes", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<MinutesSectionEntity> sections = new ArrayList<>();
@Column(name = "status", length = 20, nullable = false)
@Builder.Default
private String status = "DRAFT";
@@ -49,6 +48,9 @@ public class MinutesEntity extends BaseTimeEntity {
@Column(name = "created_by", length = 50, nullable = false)
private String createdBy;
@Column(name = "decisions", columnDefinition = "TEXT")
private String decisions;
@Column(name = "finalized_by", length = 50)
private String finalizedBy;
@@ -59,15 +61,15 @@ public class MinutesEntity extends BaseTimeEntity {
return Minutes.builder()
.minutesId(this.minutesId)
.meetingId(this.meetingId)
.userId(this.userId)
.title(this.title)
.sections(this.sections.stream()
.map(MinutesSectionEntity::toDomain)
.collect(Collectors.toList()))
.sections(List.of()) // sections는 별도 조회 필요
.status(this.status)
.version(this.version)
.createdBy(this.createdBy)
.createdAt(this.getCreatedAt())
.lastModifiedAt(this.getUpdatedAt())
.decisions(this.decisions)
.finalizedBy(this.finalizedBy)
.finalizedAt(this.finalizedAt)
.build();
@@ -77,15 +79,12 @@ public class MinutesEntity extends BaseTimeEntity {
return MinutesEntity.builder()
.minutesId(minutes.getMinutesId())
.meetingId(minutes.getMeetingId())
.userId(minutes.getUserId())
.title(minutes.getTitle())
.sections(minutes.getSections() != null
? minutes.getSections().stream()
.map(MinutesSectionEntity::fromDomain)
.collect(Collectors.toList())
: new ArrayList<>())
.status(minutes.getStatus())
.version(minutes.getVersion())
.createdBy(minutes.getCreatedBy())
.decisions(minutes.getDecisions())
.finalizedBy(minutes.getFinalizedBy())
.finalizedAt(minutes.getFinalizedAt())
.build();
@@ -10,6 +10,8 @@ import lombok.NoArgsConstructor;
/**
* 회의록 섹션 Entity
* 참석자가 작성한 메모를 안건별로 저장
* AI 분석의 입력 데이터로 사용됨
*/
@Entity
@Table(name = "minutes_sections")
@@ -21,42 +23,35 @@ public class MinutesSectionEntity extends BaseTimeEntity {
@Id
@Column(name = "id", length = 50)
private String sectionId;
private String id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "minutes_id", nullable = false)
private MinutesEntity minutes;
@Column(name = "minutes_id", insertable = false, updatable = false)
@Column(name = "minutes_id", nullable = false, length = 50)
private String minutesId;
@Column(name = "type", length = 50, nullable = false)
@Column(name = "type", length = 50)
private String type;
@Column(name = "title", length = 200, nullable = false)
@Column(name = "title", length = 200)
private String title;
@Column(name = "content", columnDefinition = "TEXT")
private String content;
@Column(name = "\"order\"")
@Builder.Default
private Integer order = 0;
@Column(name = "order")
private Integer order;
@Column(name = "verified", nullable = false)
@Builder.Default
private Boolean verified = false;
@Column(name = "verified")
private Boolean verified;
@Column(name = "locked", nullable = false)
@Builder.Default
private Boolean locked = false;
@Column(name = "locked")
private Boolean locked;
@Column(name = "locked_by", length = 50)
private String lockedBy;
public MinutesSection toDomain() {
return MinutesSection.builder()
.sectionId(this.sectionId)
.sectionId(this.id)
.minutesId(this.minutesId)
.type(this.type)
.title(this.title)
@@ -70,7 +65,7 @@ public class MinutesSectionEntity extends BaseTimeEntity {
public static MinutesSectionEntity fromDomain(MinutesSection section) {
return MinutesSectionEntity.builder()
.sectionId(section.getSectionId())
.id(section.getSectionId())
.minutesId(section.getMinutesId())
.type(section.getType())
.title(section.getTitle())
@@ -82,6 +77,10 @@ public class MinutesSectionEntity extends BaseTimeEntity {
.build();
}
public void verify() {
this.verified = true;
}
public void lock(String userId) {
this.locked = true;
this.lockedBy = userId;
@@ -91,8 +90,4 @@ public class MinutesSectionEntity extends BaseTimeEntity {
this.locked = false;
this.lockedBy = null;
}
public void verify() {
this.verified = true;
}
}
@@ -0,0 +1,47 @@
package com.unicorn.hgzero.meeting.infra.gateway.repository;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 안건별 회의록 섹션 JPA Repository
*/
@Repository
public interface AgendaSectionJpaRepository extends JpaRepository<AgendaSectionEntity, String> {
/**
* 회의 ID로 안건별 섹션 조회
* 안건 번호 순으로 정렬
*
* @param meetingId 회의 ID
* @return 안건별 섹션 목록
*/
List<AgendaSectionEntity> findByMeetingIdOrderByAgendaNumberAsc(String meetingId);
/**
* 회의록 ID로 안건별 섹션 조회
*
* @param minutesId 회의록 ID
* @return 안건별 섹션 목록
*/
List<AgendaSectionEntity> findByMinutesIdOrderByAgendaNumberAsc(String minutesId);
/**
* 회의 ID와 안건 번호로 섹션 조회
*
* @param meetingId 회의 ID
* @param agendaNumber 안건 번호
* @return 안건 섹션
*/
AgendaSectionEntity findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber);
/**
* 회의 ID로 안건별 섹션 삭제
*
* @param meetingId 회의 ID
*/
void deleteByMeetingId(String meetingId);
}
@@ -42,4 +42,15 @@ public interface MinutesJpaRepository extends JpaRepository<MinutesEntity, Strin
* 회의 ID와 버전으로 회의록 조회
*/
Optional<MinutesEntity> findByMeetingIdAndVersion(String meetingId, Integer version);
/**
* 회의 ID로 참석자별 회의록 조회 (user_id IS NOT NULL)
* AI Service가 통합 회의록 생성 시 사용
*/
List<MinutesEntity> findByMeetingIdAndUserIdIsNotNull(String meetingId);
/**
* 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL)
*/
Optional<MinutesEntity> findByMeetingIdAndUserIdIsNull(String meetingId);
}
@@ -0,0 +1,192 @@
-- ========================================
-- V3: 회의종료 기능을 위한 스키마 확장
-- ========================================
-- 작성일: 2025-10-28
-- 설명: 참석자별 회의록, 안건별 섹션, AI 요약 결과 캐싱, Todo 자동 추출 지원
-- ========================================
-- 1. minutes 테이블 확장
-- ========================================
-- 참석자별 회의록 지원 (user_id로 구분)
-- user_id가 NULL이면 AI 통합 회의록, NOT NULL이면 참석자별 회의록
ALTER TABLE minutes
ADD COLUMN IF NOT EXISTS user_id VARCHAR(100);
-- 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_minutes_meeting_user ON minutes(meeting_id, user_id);
-- 코멘트 추가
COMMENT ON COLUMN minutes.user_id IS '작성자 사용자 ID (NULL: AI 통합 회의록, NOT NULL: 참석자별 회의록)';
-- ========================================
-- 2. agenda_sections 테이블 생성
-- ========================================
-- 안건별 AI 요약 결과 저장
CREATE TABLE IF NOT EXISTS agenda_sections (
id VARCHAR(36) PRIMARY KEY,
minutes_id VARCHAR(36) NOT NULL,
meeting_id VARCHAR(50) NOT NULL,
-- 안건 정보
agenda_number INT NOT NULL,
agenda_title VARCHAR(200) NOT NULL,
-- AI 요약 결과
ai_summary_short TEXT,
discussions TEXT,
decisions JSON,
pending_items JSON,
opinions JSON,
-- 메타데이터
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 외래키
CONSTRAINT fk_agenda_sections_minutes
FOREIGN KEY (minutes_id) REFERENCES minutes(id)
ON DELETE CASCADE,
CONSTRAINT fk_agenda_sections_meeting
FOREIGN KEY (meeting_id) REFERENCES meetings(meeting_id)
ON DELETE CASCADE
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_sections_meeting ON agenda_sections(meeting_id);
CREATE INDEX IF NOT EXISTS idx_sections_agenda ON agenda_sections(meeting_id, agenda_number);
CREATE INDEX IF NOT EXISTS idx_sections_minutes ON agenda_sections(minutes_id);
-- 코멘트 추가
COMMENT ON TABLE agenda_sections IS '안건별 회의록 섹션 - AI 요약 결과 저장';
COMMENT ON COLUMN agenda_sections.id IS '섹션 고유 ID';
COMMENT ON COLUMN agenda_sections.minutes_id IS '회의록 ID (통합 회의록 참조)';
COMMENT ON COLUMN agenda_sections.meeting_id IS '회의 ID';
COMMENT ON COLUMN agenda_sections.agenda_number IS '안건 번호 (1, 2, 3...)';
COMMENT ON COLUMN agenda_sections.agenda_title IS '안건 제목';
COMMENT ON COLUMN agenda_sections.ai_summary_short IS 'AI 생성 짧은 요약 (1줄, 20자 이내)';
COMMENT ON COLUMN agenda_sections.discussions IS '논의 사항 (핵심 내용 3-5문장)';
COMMENT ON COLUMN agenda_sections.decisions IS '결정 사항 배열 (JSON)';
COMMENT ON COLUMN agenda_sections.pending_items IS '보류 사항 배열 (JSON)';
COMMENT ON COLUMN agenda_sections.opinions IS '참석자별 의견 (JSON: [{speaker, opinion}])';
-- ========================================
-- 3. ai_summaries 테이블 생성
-- ========================================
-- AI 요약 결과 캐싱 및 성능 최적화
CREATE TABLE IF NOT EXISTS ai_summaries (
id VARCHAR(36) PRIMARY KEY,
meeting_id VARCHAR(50) NOT NULL,
summary_type VARCHAR(50) NOT NULL,
-- 입력 정보
source_minutes_ids JSON NOT NULL,
-- AI 처리 결과
result JSON NOT NULL,
processing_time_ms INT,
model_version VARCHAR(50) DEFAULT 'claude-3.5-sonnet',
-- 통계 정보
keywords JSON,
statistics JSON,
-- 메타데이터
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- 외래키
CONSTRAINT fk_ai_summaries_meeting
FOREIGN KEY (meeting_id) REFERENCES meetings(meeting_id)
ON DELETE CASCADE
);
-- 인덱스 생성
CREATE INDEX IF NOT EXISTS idx_summaries_meeting ON ai_summaries(meeting_id);
CREATE INDEX IF NOT EXISTS idx_summaries_type ON ai_summaries(meeting_id, summary_type);
CREATE INDEX IF NOT EXISTS idx_summaries_created ON ai_summaries(created_at);
-- 코멘트 추가
COMMENT ON TABLE ai_summaries IS 'AI 요약 결과 캐시 테이블';
COMMENT ON COLUMN ai_summaries.id IS '요약 결과 고유 ID';
COMMENT ON COLUMN ai_summaries.meeting_id IS '회의 ID';
COMMENT ON COLUMN ai_summaries.summary_type IS '요약 타입 (CONSOLIDATED: 통합 요약, TODO_EXTRACTION: Todo 추출)';
COMMENT ON COLUMN ai_summaries.source_minutes_ids IS '통합에 사용된 회의록 ID 배열 (JSON)';
COMMENT ON COLUMN ai_summaries.result IS 'AI 응답 전체 결과 (JSON)';
COMMENT ON COLUMN ai_summaries.processing_time_ms IS 'AI 처리 시간 (밀리초)';
COMMENT ON COLUMN ai_summaries.model_version IS '사용한 AI 모델 버전';
COMMENT ON COLUMN ai_summaries.keywords IS '주요 키워드 배열 (JSON)';
COMMENT ON COLUMN ai_summaries.statistics IS '통계 정보 (참석자 수, 안건 수 등, JSON)';
-- ========================================
-- 4. todos 테이블 확장
-- ========================================
-- AI 자동 추출 정보 추가
ALTER TABLE todos
ADD COLUMN IF NOT EXISTS extracted_by VARCHAR(50) DEFAULT 'AI',
ADD COLUMN IF NOT EXISTS section_reference VARCHAR(200),
ADD COLUMN IF NOT EXISTS extraction_confidence DECIMAL(3,2) DEFAULT 0.00;
-- 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_todos_extracted ON todos(extracted_by);
CREATE INDEX IF NOT EXISTS idx_todos_meeting ON todos(meeting_id);
-- 코멘트 추가
COMMENT ON COLUMN todos.extracted_by IS 'Todo 추출 방법 (AI: AI 자동 추출, MANUAL: 사용자 수동 작성)';
COMMENT ON COLUMN todos.section_reference IS '관련 회의록 섹션 참조 (예: "안건 1", "결정사항 #3")';
COMMENT ON COLUMN todos.extraction_confidence IS 'AI 추출 신뢰도 점수 (0.00~1.00)';
-- ========================================
-- 5. 제약조건 및 트리거 추가
-- ========================================
-- updated_at 자동 업데이트 함수 (PostgreSQL)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- agenda_sections 테이블에 updated_at 트리거 추가
DROP TRIGGER IF EXISTS update_agenda_sections_updated_at ON agenda_sections;
CREATE TRIGGER update_agenda_sections_updated_at
BEFORE UPDATE ON agenda_sections
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- ========================================
-- 6. 샘플 데이터 (개발 환경용)
-- ========================================
-- 실제 운영 환경에서는 주석 처리
-- 샘플 회의록 (참석자별)
-- INSERT INTO minutes (id, meeting_id, user_id, content, created_at)
-- VALUES
-- ('sample-minutes-1', 'sample-meeting-1', 'user1@example.com', '회의록 내용 1...', CURRENT_TIMESTAMP),
-- ('sample-minutes-2', 'sample-meeting-1', 'user2@example.com', '회의록 내용 2...', CURRENT_TIMESTAMP);
-- 샘플 통합 회의록 (user_id가 NULL)
-- INSERT INTO minutes (id, meeting_id, user_id, content, created_at)
-- VALUES
-- ('sample-minutes-consolidated', 'sample-meeting-1', NULL, 'AI 통합 회의록...', CURRENT_TIMESTAMP);
-- 샘플 안건 섹션
-- INSERT INTO agenda_sections (id, minutes_id, meeting_id, agenda_number, agenda_title, ai_summary_short, discussions, decisions, pending_items, opinions)
-- VALUES
-- ('sample-section-1', 'sample-minutes-consolidated', 'sample-meeting-1', 1, '신제품 기획 방향성',
-- '타겟 고객을 20-30대로 설정...',
-- '신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고...',
-- '["타겟 고객: 20-30대 직장인", "UI/UX 개선을 최우선 과제로 설정"]'::json,
-- '[]'::json,
-- '[{"speaker": "김민준", "opinion": "타겟 고객층을 명확히 설정하여 마케팅 전략 수립 필요"}]'::json);
-- ========================================
-- 7. 권한 설정 (필요시)
-- ========================================
-- GRANT SELECT, INSERT, UPDATE, DELETE ON agenda_sections TO meeting_service_user;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ai_summaries TO meeting_service_user;
-- GRANT SELECT, INSERT, UPDATE, DELETE ON ai_summaries TO ai_service_user;
@@ -0,0 +1,37 @@
-- ========================================
-- V4: agenda_sections 테이블에 todos 컬럼 추가
-- ========================================
-- 작성일: 2025-10-28
-- 설명: AI가 추출한 Todo를 안건별 섹션에 저장
-- ========================================
-- 1. agenda_sections 테이블에 todos 컬럼 추가
-- ========================================
ALTER TABLE agenda_sections
ADD COLUMN IF NOT EXISTS todos JSON;
-- 코멘트 추가
COMMENT ON COLUMN agenda_sections.todos IS 'AI 추출 Todo 목록 (JSON: [{title, assignee, dueDate, description, priority}])';
-- ========================================
-- 2. 샘플 데이터 구조 (참고용)
-- ========================================
--
-- todos JSON 구조:
-- [
-- {
-- "title": "시장 조사 보고서 작성",
-- "assignee": "김민준",
-- "dueDate": "2025-02-15",
-- "description": "20-30대 타겟 시장 조사",
-- "priority": "HIGH"
-- },
-- {
-- "title": "UI/UX 개선안 초안 작성",
-- "assignee": "이서연",
-- "dueDate": "2025-02-20",
-- "description": "모바일 우선 UI 개선",
-- "priority": "MEDIUM"
-- }
-- ]
@@ -0,0 +1,52 @@
-- ========================================
-- V5: minutes_sections 테이블 재생성
-- ========================================
-- 작성일: 2025-10-28
-- 설명: minutes_sections 테이블을 Entity 구조에 맞게 재생성
-- 1. 기존 테이블이 있으면 삭제
DROP TABLE IF EXISTS minutes_sections CASCADE;
-- 2. Entity 구조에 맞는 테이블 생성
CREATE TABLE minutes_sections (
id VARCHAR(50) PRIMARY KEY,
minutes_id VARCHAR(50) NOT NULL,
type VARCHAR(50),
title VARCHAR(200),
content TEXT,
"order" INTEGER,
verified BOOLEAN DEFAULT FALSE,
locked BOOLEAN DEFAULT FALSE,
locked_by VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_minutes_sections_minutes
FOREIGN KEY (minutes_id) REFERENCES minutes(id)
ON DELETE CASCADE
);
-- 3. 인덱스 생성
CREATE INDEX idx_minutes_sections_minutes ON minutes_sections(minutes_id);
CREATE INDEX idx_minutes_sections_order ON minutes_sections(minutes_id, "order");
CREATE INDEX idx_minutes_sections_type ON minutes_sections(type);
CREATE INDEX idx_minutes_sections_verified ON minutes_sections(verified);
-- 4. 코멘트 추가
COMMENT ON TABLE minutes_sections IS '참석자별 회의록 안건 섹션 - AI 통합 회의록 생성 입력 데이터';
COMMENT ON COLUMN minutes_sections.id IS '섹션 고유 ID';
COMMENT ON COLUMN minutes_sections.minutes_id IS '참석자별 회의록 ID (minutes.id 참조)';
COMMENT ON COLUMN minutes_sections.type IS '섹션 타입 (AGENDA: 안건, DISCUSSION: 논의사항, DECISION: 결정사항 등)';
COMMENT ON COLUMN minutes_sections.title IS '섹션 제목';
COMMENT ON COLUMN minutes_sections.content IS '섹션 내용 (참석자가 작성한 메모)';
COMMENT ON COLUMN minutes_sections."order" IS '섹션 순서';
COMMENT ON COLUMN minutes_sections.verified IS '검증 완료 여부';
COMMENT ON COLUMN minutes_sections.locked IS '편집 잠금 여부';
COMMENT ON COLUMN minutes_sections.locked_by IS '잠금 설정한 사용자 ID';
-- 5. updated_at 자동 업데이트 트리거
DROP TRIGGER IF EXISTS update_minutes_sections_updated_at ON minutes_sections;
CREATE TRIGGER update_minutes_sections_updated_at
BEFORE UPDATE ON minutes_sections
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
@@ -0,0 +1,58 @@
-- ========================================
-- V7: minutes 테이블에 decisions 추가 및 agenda_sections 리팩토링
-- ========================================
-- 작성일: 2025-10-29
-- 설명:
-- 1. minutes 테이블에 decisions (결정사항) 컬럼 추가
-- 2. agenda_sections 테이블에서 discussions, decisions, opinions를 summary로 통합
-- ========================================
-- 1. minutes 테이블에 decisions 컬럼 추가
-- ========================================
-- 회의 전체 결정사항을 TEXT 형식으로 저장
ALTER TABLE minutes
ADD COLUMN IF NOT EXISTS decisions TEXT;
COMMENT ON COLUMN minutes.decisions IS '회의 전체 결정사항 (회의록 수정 시 입력)';
-- ========================================
-- 2. agenda_sections 테이블 백업 및 데이터 마이그레이션
-- ========================================
-- 기존 데이터 보존을 위한 임시 백업 테이블 생성
CREATE TABLE IF NOT EXISTS agenda_sections_backup AS
SELECT * FROM agenda_sections;
-- ========================================
-- 3. agenda_sections 테이블 컬럼 변경
-- ========================================
-- discussions, decisions, opinions → summary 통합
-- summary 컬럼 추가 (기존 discussions 내용으로 초기화)
ALTER TABLE agenda_sections
ADD COLUMN IF NOT EXISTS summary TEXT;
-- 기존 데이터 마이그레이션: discussions 내용을 summary로 복사
UPDATE agenda_sections
SET summary = COALESCE(discussions, '');
-- 기존 컬럼 삭제
ALTER TABLE agenda_sections
DROP COLUMN IF EXISTS discussions,
DROP COLUMN IF EXISTS decisions,
DROP COLUMN IF EXISTS opinions;
-- 코멘트 추가
COMMENT ON COLUMN agenda_sections.summary IS '안건별 회의록 요약 (사용자가 입력한 회의록 내용을 AI가 요약한 결과)';
-- ========================================
-- 4. 인덱스 및 트리거 유지
-- ========================================
-- 기존 인덱스 및 트리거는 그대로 유지됨
-- ========================================
-- 5. 백업 테이블 정리 안내
-- ========================================
-- agenda_sections_backup 테이블은 수동으로 검증 후 삭제
COMMENT ON TABLE agenda_sections_backup IS 'V7 마이그레이션 백업 - 검증 후 수동 삭제 필요';
@@ -0,0 +1,111 @@
package com.unicorn.hgzero.meeting.manual;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
/**
* 테스트 데이터 삽입 스크립트
* 실행: ./gradlew :meeting:bootRun --args='--spring.profiles.active=test'
*/
@SpringBootApplication(scanBasePackages = "com.unicorn.hgzero.meeting")
public class InsertTestData {
public static void main(String[] args) {
SpringApplication.run(InsertTestData.class, args);
}
@Bean
public CommandLineRunner insertData(JdbcTemplate jdbcTemplate) {
return args -> {
System.out.println("===== 테스트 데이터 삽입 시작 =====");
// 1. 참석자 회의록 삽입
insertParticipantMinutes(jdbcTemplate);
// 2. 회의록 섹션 삽입
insertMinutesSections(jdbcTemplate);
// 3. 데이터 확인
verifyData(jdbcTemplate);
System.out.println("===== 테스트 데이터 삽입 완료 =====");
};
}
private void insertParticipantMinutes(JdbcTemplate jdbc) {
System.out.println("참석자 회의록 삽입 중...");
String[] inserts = {
"INSERT INTO minutes (minutes_id, meeting_id, user_id, title, status, version, created_by, created_at, updated_at) " +
"VALUES ('minutes-user1', 'meeting-123', 'user-001', '참석자 홍길동 회의록', 'DRAFT', 1, 'user-001', NOW(), NOW()) " +
"ON CONFLICT (minutes_id) DO NOTHING",
"INSERT INTO minutes (minutes_id, meeting_id, user_id, title, status, version, created_by, created_at, updated_at) " +
"VALUES ('minutes-user2', 'meeting-123', 'user-002', '참석자 김철수 회의록', 'DRAFT', 1, 'user-002', NOW(), NOW()) " +
"ON CONFLICT (minutes_id) DO NOTHING",
"INSERT INTO minutes (minutes_id, meeting_id, user_id, title, status, version, created_by, created_at, updated_at) " +
"VALUES ('minutes-user3', 'meeting-123', 'user-003', '참석자 이영희 회의록', 'DRAFT', 1, 'user-003', NOW(), NOW()) " +
"ON CONFLICT (minutes_id) DO NOTHING"
};
for (String sql : inserts) {
jdbc.execute(sql);
}
System.out.println("참석자 회의록 삽입 완료");
}
private void insertMinutesSections(JdbcTemplate jdbc) {
System.out.println("회의록 섹션 삽입 중...");
// 참석자 1 섹션
insertSection(jdbc, "minutes-user1", 1, "프로젝트 목표 논의",
"고객사 요구사항이 명확하지 않아 추가 미팅 필요. 우선순위는 성능 개선으로 결정.");
insertSection(jdbc, "minutes-user1", 2, "기술 스택 검토",
"React와 Spring Boot로 진행하기로 결정. DB는 PostgreSQL 사용.");
// 참석자 2 섹션
insertSection(jdbc, "minutes-user2", 1, "프로젝트 목표 논의",
"성능 개선이 가장 중요. 응답시간 목표는 200ms 이내로 설정.");
insertSection(jdbc, "minutes-user2", 2, "기술 스택 검토",
"캐시 전략으로 Redis 도입 검토 필요. 모니터링 도구는 Prometheus 사용.");
// 참석자 3 섹션
insertSection(jdbc, "minutes-user3", 1, "프로젝트 목표 논의",
"고객사 담당자와 다음 주 화요일에 추가 미팅 예정. 요구사항 명세서 작성 필요.");
insertSection(jdbc, "minutes-user3", 2, "기술 스택 검토",
"UI 라이브러리는 Material-UI 사용. 백엔드는 MSA 아키텍처 검토.");
System.out.println("회의록 섹션 삽입 완료");
}
private void insertSection(JdbcTemplate jdbc, String minutesId, int sectionNum, String title, String content) {
String sql = "INSERT INTO minutes_sections (minutes_id, section_number, section_title, content, created_at) " +
"SELECT id, ?, ?, ?, NOW() FROM minutes WHERE minutes_id = ? " +
"ON CONFLICT DO NOTHING";
jdbc.update(sql, sectionNum, title, content, minutesId);
}
private void verifyData(JdbcTemplate jdbc) {
System.out.println("\n===== 데이터 확인 =====");
Integer minutesCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM minutes WHERE meeting_id = 'meeting-123' AND user_id IS NOT NULL",
Integer.class
);
System.out.println("참석자 회의록 개수: " + minutesCount);
Integer sectionsCount = jdbc.queryForObject(
"SELECT COUNT(*) FROM minutes_sections ms " +
"JOIN minutes m ON ms.minutes_id = m.id " +
"WHERE m.meeting_id = 'meeting-123'",
Integer.class
);
System.out.println("회의록 섹션 개수: " + sectionsCount);
}
}
@@ -0,0 +1,130 @@
-- 테스트용 참석자 회의록(minutes) 데이터
-- userId가 있는 회의록들 (참석자별 메모)
-- 참석자 1의 회의록
INSERT INTO minutes (minutes_id, meeting_id, user_id, title, status, version, created_by, created_at, updated_at)
VALUES ('minutes-user1', 'meeting-123', 'user-001', '참석자 홍길동 회의록', 'DRAFT', 1, 'user-001', NOW(), NOW())
ON CONFLICT (minutes_id) DO NOTHING;
-- 참석자 2의 회의록
INSERT INTO minutes (minutes_id, meeting_id, user_id, title, status, version, created_by, created_at, updated_at)
VALUES ('minutes-user2', 'meeting-123', 'user-002', '참석자 김철수 회의록', 'DRAFT', 1, 'user-002', NOW(), NOW())
ON CONFLICT (minutes_id) DO NOTHING;
-- 참석자 3의 회의록
INSERT INTO minutes (minutes_id, meeting_id, user_id, title, status, version, created_by, created_at, updated_at)
VALUES ('minutes-user3', 'meeting-123', 'user-003', '참석자 이영희 회의록', 'DRAFT', 1, 'user-003', NOW(), NOW())
ON CONFLICT (minutes_id) DO NOTHING;
-- minutes_sections 데이터 삽입
-- Entity 구조에 맞게 수정: id, minutes_id, type, title, content, "order", verified, locked, locked_by
-- 참석자 1 (홍길동)의 메모
INSERT INTO minutes_sections (id, minutes_id, type, title, content, "order", verified, locked, locked_by, created_at, updated_at)
SELECT
'section-user1-1',
'minutes-user1',
'AGENDA',
'프로젝트 목표 논의',
'고객사 요구사항이 명확하지 않아 추가 미팅 필요. 우선순위는 성능 개선으로 결정.',
1,
FALSE,
FALSE,
NULL,
NOW(),
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM minutes_sections WHERE id = 'section-user1-1'
);
INSERT INTO minutes_sections (id, minutes_id, type, title, content, "order", verified, locked, locked_by, created_at, updated_at)
SELECT
'section-user1-2',
'minutes-user1',
'AGENDA',
'기술 스택 검토',
'React와 Spring Boot로 진행하기로 결정. DB는 PostgreSQL 사용.',
2,
FALSE,
FALSE,
NULL,
NOW(),
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM minutes_sections WHERE id = 'section-user1-2'
);
-- 참석자 2 (김철수)의 메모
INSERT INTO minutes_sections (id, minutes_id, type, title, content, "order", verified, locked, locked_by, created_at, updated_at)
SELECT
'section-user2-1',
'minutes-user2',
'AGENDA',
'프로젝트 목표 논의',
'성능 개선이 가장 중요. 응답시간 목표는 200ms 이내로 설정.',
1,
FALSE,
FALSE,
NULL,
NOW(),
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM minutes_sections WHERE id = 'section-user2-1'
);
INSERT INTO minutes_sections (id, minutes_id, type, title, content, "order", verified, locked, locked_by, created_at, updated_at)
SELECT
'section-user2-2',
'minutes-user2',
'AGENDA',
'기술 스택 검토',
'캐시 전략으로 Redis 도입 검토 필요. 모니터링 도구는 Prometheus 사용.',
2,
FALSE,
FALSE,
NULL,
NOW(),
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM minutes_sections WHERE id = 'section-user2-2'
);
-- 참석자 3 (이영희)의 메모
INSERT INTO minutes_sections (id, minutes_id, type, title, content, "order", verified, locked, locked_by, created_at, updated_at)
SELECT
'section-user3-1',
'minutes-user3',
'AGENDA',
'프로젝트 목표 논의',
'고객사 담당자와 다음 주 화요일에 추가 미팅 예정. 요구사항 명세서 작성 필요.',
1,
FALSE,
FALSE,
NULL,
NOW(),
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM minutes_sections WHERE id = 'section-user3-1'
);
INSERT INTO minutes_sections (id, minutes_id, type, title, content, "order", verified, locked, locked_by, created_at, updated_at)
SELECT
'section-user3-2',
'minutes-user3',
'AGENDA',
'기술 스택 검토',
'UI 라이브러리는 Material-UI 사용. 백엔드는 MSA 아키텍처 검토.',
2,
FALSE,
FALSE,
NULL,
NOW(),
NOW()
WHERE NOT EXISTS (
SELECT 1 FROM minutes_sections WHERE id = 'section-user3-2'
);
-- 확인 쿼리
SELECT 'Test data inserted successfully!' as status;
SELECT COUNT(*) as minutes_count FROM minutes WHERE meeting_id = 'meeting-123';
SELECT COUNT(*) as sections_count FROM minutes_sections;