mirror of
https://github.com/hwanny1128/HGZero.git
synced 2026-06-12 22:59:10 +00:00
for merge
This commit is contained in:
@@ -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`
|
||||
@@ -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` 파일 참조
|
||||
@@ -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';
|
||||
Executable
+40
@@ -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 "========================================="
|
||||
@@ -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;
|
||||
@@ -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 +0,0 @@
|
||||
nohup: ./gradlew: No such file or directory
|
||||
@@ -1 +0,0 @@
|
||||
nohup: ./gradlew: No such file or directory
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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);
|
||||
}
|
||||
+41
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+261
@@ -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;
|
||||
}
|
||||
+58
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+92
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -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;
|
||||
}
|
||||
+89
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+94
@@ -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)
|
||||
|
||||
+7
-2
@@ -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())
|
||||
|
||||
+11
-12
@@ -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();
|
||||
|
||||
+18
-23
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+47
@@ -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);
|
||||
}
|
||||
+11
@@ -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();
|
||||
+58
@@ -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;
|
||||
Reference in New Issue
Block a user