for merge

This commit is contained in:
djeon
2025-10-31 14:55:33 +09:00
26 changed files with 29442 additions and 1673 deletions
@@ -103,8 +103,8 @@ public class DashboardDTO {
.meetingId(meeting.getMeetingId())
.title(meeting.getTitle())
.startTime(meeting.getScheduledAt())
.endTime(null) // Meeting 도메인에 endTime이 없음
.location(null) // Meeting 도메인에 location이 없음
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0)
.status(meeting.getStatus())
.userRole(currentUserId.equals(meeting.getOrganizerId()) ? "CREATOR" : "PARTICIPANT")
@@ -41,5 +41,6 @@ public class MeetingEndDTO {
@Builder
public static class TodoSummaryDTO {
private final String title;
private final String assignee;
}
}
@@ -125,6 +125,12 @@ public class MinutesDTO {
* 참석자 수
*/
private final Integer participantCount;
/**
* 검증완료율 (작성중 상태일 때만 유효)
* 0-100 사이의 값
*/
private final Integer verificationRate;
/**
* 회의 정보
@@ -31,6 +31,7 @@ import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.stream.Collectors;
@@ -106,19 +107,25 @@ public class EndMeetingService implements EndMeetingUseCase {
ConsolidateResponse aiResponse = aiServiceClient.consolidateMinutes(request);
log.info("AI Service 호출 완료 - 안건 수: {}", aiResponse.getAgendaSummaries().size());
// 6. AI 분석 결과 저장
MeetingAnalysis analysis = saveAnalysisResult(meeting, aiResponse);
// 6. 통합 회의록 생성 또는 조회
MinutesEntity consolidatedMinutes = getOrCreateConsolidatedMinutes(meeting);
// 7. Todo 생성 및 저장
// 7. 통합 회의록에 전체 결정사항 저장
saveConsolidatedDecisions(consolidatedMinutes, aiResponse.getDecisions());
// 8. AI 분석 결과 저장
MeetingAnalysis analysis = saveAnalysisResult(meeting, consolidatedMinutes, aiResponse);
// 9. Todo 생성 및 저장
List<TodoEntity> todos = createAndSaveTodos(meeting, aiResponse, analysis);
// 8. 회의 종료 처리
// 10. 회의 종료 처리
meeting.end();
meetingRepository.save(meeting);
log.info("회의 상태 업데이트 완료 - status: {}", meeting.getStatus());
// 9. 응답 DTO 생성
MeetingEndDTO result = createMeetingEndDTO(meeting, analysis, todos, participantMinutesList.size());
// 9. 응답 DTO 생성 (AI 응답의 todos를 그대로 사용)
MeetingEndDTO result = createMeetingEndDTO(meeting, aiResponse, todos.size(), participantMinutesList.size());
log.info("회의 종료 처리 완료 - meetingId: {}, 안건 수: {}, Todo 수: {}",
meetingId, analysis.getAgendaAnalyses().size(), todos.size());
@@ -164,10 +171,55 @@ public class EndMeetingService implements EndMeetingUseCase {
.build();
}
/**
* 통합 회의록 생성 또는 조회
* userId가 NULL인 회의록 = AI 통합 회의록
*/
private MinutesEntity getOrCreateConsolidatedMinutes(MeetingEntity meeting) {
// userId가 NULL인 회의록 찾기 (AI 통합 회의록)
List<MinutesEntity> existingList = minutesRepository
.findByMeetingIdAndUserIdIsNull(meeting.getMeetingId());
if (!existingList.isEmpty()) {
MinutesEntity existing = existingList.get(0);
log.debug("기존 통합 회의록 사용 - minutesId: {}", existing.getMinutesId());
return existing;
}
// 없으면 새로 생성
MinutesEntity consolidatedMinutes = MinutesEntity.builder()
.minutesId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.userId(null) // NULL = AI 통합 회의록
.title(meeting.getTitle() + " - AI 통합 회의록")
.status("FINALIZED")
.version(1)
.createdBy("AI")
.build();
MinutesEntity saved = minutesRepository.save(consolidatedMinutes);
log.info("통합 회의록 생성 완료 - minutesId: {}", saved.getMinutesId());
return saved;
}
/**
* 통합 회의록에 전체 결정사항 저장
*/
private void saveConsolidatedDecisions(MinutesEntity minutes, String decisions) {
if (decisions != null && !decisions.trim().isEmpty()) {
minutes.updateDecisions(decisions);
minutesRepository.save(minutes);
log.info("Minutes에 전체 결정사항 저장 완료 - minutesId: {}, 길이: {}",
minutes.getMinutesId(), decisions.length());
} else {
log.warn("저장할 결정사항이 없음 - minutesId: {}", minutes.getMinutesId());
}
}
/**
* AI 분석 결과 저장
*/
private MeetingAnalysis saveAnalysisResult(MeetingEntity meeting, ConsolidateResponse aiResponse) {
private MeetingAnalysis saveAnalysisResult(MeetingEntity meeting, MinutesEntity consolidatedMinutes, ConsolidateResponse aiResponse) {
// AgendaAnalysis 리스트 생성
List<MeetingAnalysis.AgendaAnalysis> agendaAnalyses = aiResponse.getAgendaSummaries().stream()
.<MeetingAnalysis.AgendaAnalysis>map(summary -> MeetingAnalysis.AgendaAnalysis.builder()
@@ -203,7 +255,7 @@ public class EndMeetingService implements EndMeetingUseCase {
analysisRepository.save(entity);
// AgendaSection 저장 (안건별 회의록)
saveAgendaSections(meeting.getMeetingId(), aiResponse);
saveAgendaSections(meeting.getMeetingId(), consolidatedMinutes.getMinutesId(), aiResponse);
log.info("AI 분석 결과 저장 완료 - analysisId: {}", analysis.getAnalysisId());
@@ -213,7 +265,7 @@ public class EndMeetingService implements EndMeetingUseCase {
/**
* AgendaSection 저장
*/
private void saveAgendaSections(String meetingId, ConsolidateResponse aiResponse) {
private void saveAgendaSections(String meetingId, String minutesId, ConsolidateResponse aiResponse) {
int agendaNumber = 1;
for (var summary : aiResponse.getAgendaSummaries()) {
@@ -229,7 +281,7 @@ public class EndMeetingService implements EndMeetingUseCase {
AgendaSectionEntity agendaSection = AgendaSectionEntity.builder()
.id(UUID.randomUUID().toString())
.minutesId(meetingId) // AI 통합 회의록 ID로 사용
.minutesId(minutesId) // 통합 회의록 ID
.meetingId(meetingId)
.agendaNumber(agendaNumber++)
.agendaTitle(summary.getAgendaTitle())
@@ -247,7 +299,8 @@ public class EndMeetingService implements EndMeetingUseCase {
}
}
log.info("AgendaSection 저장 완료 - meetingId: {}, count: {}", meetingId, aiResponse.getAgendaSummaries().size());
log.info("AgendaSection 저장 완료 - meetingId: {}, minutesId: {}, count: {}",
meetingId, minutesId, aiResponse.getAgendaSummaries().size());
}
/**
@@ -256,7 +309,8 @@ public class EndMeetingService implements EndMeetingUseCase {
private List<TodoEntity> createAndSaveTodos(MeetingEntity meeting, ConsolidateResponse aiResponse, MeetingAnalysis analysis) {
List<TodoEntity> todos = aiResponse.getAgendaSummaries().stream()
.<TodoEntity>flatMap(agenda -> {
// agendaId는 향후 Todo와 안건 매핑에 사용될 수 있음 (현재는 사용하지 않음)
// 안건 번호를 description에 저장하여 나중에 필터링에 사용
Integer agendaNumber = agenda.getAgendaNumber();
List<ExtractedTodoDTO> todoList = agenda.getTodos() != null ? agenda.getTodos() : List.of();
return todoList.stream()
.<TodoEntity>map(todo -> TodoEntity.builder()
@@ -264,6 +318,7 @@ public class EndMeetingService implements EndMeetingUseCase {
.meetingId(meeting.getMeetingId())
.minutesId(meeting.getMeetingId()) // 실제로는 minutesId 필요
.title(todo.getTitle())
.description("안건" + agendaNumber) // 안건 번호를 description에 임시 저장
.assigneeId(todo.getAssignee() != null ? todo.getAssignee() : "") // AI가 추출한 담당자
.status("PENDING")
.build());
@@ -290,33 +345,36 @@ public class EndMeetingService implements EndMeetingUseCase {
}
/**
* 회의 종료 결과 DTO 생성
* 회의 종료 결과 DTO 생성 (AI 응답 직접 사용)
*/
private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, MeetingAnalysis analysis,
List<TodoEntity> todos, int participantCount) {
private MeetingEndDTO createMeetingEndDTO(MeetingEntity meeting, ConsolidateResponse aiResponse,
int todoCount, int participantCount) {
// 회의 소요 시간 계산
int durationMinutes = calculateDurationMinutes(meeting.getStartedAt(), meeting.getEndedAt());
// 안건별 요약 DTO 생성
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = analysis.getAgendaAnalyses().stream()
// AI 응답의 안건 정보를 그대로 DTO로 변환 (todos 포함)
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = aiResponse.getAgendaSummaries().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()
// 안건별 todos 변환
List<MeetingEndDTO.TodoSummaryDTO> todoList = new ArrayList<>();
if (agenda.getTodos() != null) {
for (ExtractedTodoDTO todo : agenda.getTodos()) {
todoList.add(MeetingEndDTO.TodoSummaryDTO.builder()
.title(todo.getTitle())
.build())
.collect(Collectors.toList());
.assignee(todo.getAssignee())
.build());
}
}
return MeetingEndDTO.AgendaSummaryDTO.builder()
.title(agenda.getTitle())
.aiSummaryShort(agenda.getAiSummaryShort())
.title(agenda.getAgendaTitle())
.aiSummaryShort(agenda.getSummaryShort())
.details(MeetingEndDTO.AgendaDetailsDTO.builder()
.discussion(agenda.getDiscussion())
.discussion(agenda.getSummary())
.decisions(agenda.getDecisions())
.pending(agenda.getPending())
.build())
.todos(agendaTodos)
.todos(todoList)
.build();
})
.collect(Collectors.toList());
@@ -325,9 +383,9 @@ public class EndMeetingService implements EndMeetingUseCase {
.title(meeting.getTitle())
.participantCount(participantCount)
.durationMinutes(durationMinutes)
.agendaCount(analysis.getAgendaAnalyses().size())
.todoCount(todos.size())
.keywords(analysis.getKeywords())
.agendaCount(aiResponse.getAgendaSummaries().size())
.todoCount(todoCount)
.keywords(aiResponse.getKeywords())
.agendaSummaries(agendaSummaries)
.build();
}
@@ -240,20 +240,21 @@ public class MinutesService implements
/**
* 사용자 ID로 회의록 목록 조회 (페이징)
* 사용자가 생성했거나 참여한 회의의 회의록을 모두 조회
*/
@Transactional(readOnly = true)
public Page<MinutesDTO> getMinutesListByUserId(String userId, Pageable pageable) {
log.debug("Getting minutes list by userId: {}", userId);
// 여기서는 임시로 작성자 기준으로 조회 (실제로는 참석자나 권한 기반으로 조회해야 함)
List<Minutes> minutesList = minutesReader.findByCreatedBy(userId);
// 사용자가 생성했거나 참여한 회의의 회의록 조회
List<Minutes> minutesList = minutesReader.findByParticipantUserId(userId);
// Minutes를 MinutesDTO로 변환
List<MinutesDTO> minutesDTOList = minutesList.stream()
.map(this::convertToMinutesDTO)
.collect(Collectors.toList());
// 페이징 처리 (임시로 전체 목록 반환)
// 페이징 처리
int start = (int) pageable.getOffset();
int end = Math.min((start + pageable.getPageSize()), minutesDTOList.size());
List<MinutesDTO> pageContent = minutesDTOList.subList(start, end);
@@ -371,6 +372,15 @@ public class MinutesService implements
log.warn("섹션 정보 변환 실패 - minutesId: {}", minutes.getMinutesId(), e);
}
// 검증완료율 계산 (작성중 상태일 때만)
Integer verificationRate = null;
if ("DRAFT".equals(minutes.getStatus()) && sectionDTOs != null && !sectionDTOs.isEmpty()) {
long verifiedCount = sectionDTOs.stream()
.filter(section -> Boolean.TRUE.equals(section.getIsVerified()))
.count();
verificationRate = (int) ((verifiedCount * 100) / sectionDTOs.size());
}
// decisions 값 로깅
log.info("Minutes decisions 값 확인 - minutesId: {}, decisions: {}",
minutes.getMinutesId(), minutes.getDecisions());
@@ -389,6 +399,7 @@ public class MinutesService implements
.todoCount(todoCount)
.completedTodoCount(completedTodoCount)
.participantCount(participantCount)
.verificationRate(verificationRate)
.memo("") // 메모 필드는 추후 구현
.sections(sectionDTOs) // 섹션 정보 추가
.decisions(minutes.getDecisions()) // decisions 필드 추가
@@ -56,4 +56,13 @@ public interface MinutesReader {
* @return AI 통합 회의록
*/
Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId);
/**
* 사용자가 참여한 회의의 회의록 목록 조회
* 사용자가 생성했거나 참여한 회의의 회의록을 모두 조회
*
* @param userId 사용자 ID
* @return 회의록 목록
*/
List<Minutes> findByParticipantUserId(String userId);
}
@@ -3,15 +3,12 @@ package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.common.exception.BusinessException;
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.domain.Todo;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.service.MeetingService;
import com.unicorn.hgzero.meeting.biz.service.MinutesService;
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
import com.unicorn.hgzero.meeting.biz.service.TodoService;
import com.unicorn.hgzero.meeting.biz.service.AgendaSectionService;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateMinutesRequest;
import com.unicorn.hgzero.meeting.infra.dto.request.UpdateAgendaSectionsRequest;
@@ -21,7 +18,6 @@ import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import com.unicorn.hgzero.meeting.infra.event.publisher.EventPublisher;
import com.unicorn.hgzero.meeting.infra.gateway.AiServiceGateway;
import com.unicorn.hgzero.meeting.biz.dto.AiAnalysisDTO;
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesAnalysisRequestEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesFinalizedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MinutesSectionDTO;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
@@ -40,17 +36,10 @@ import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -69,7 +58,6 @@ public class MinutesController {
private final CacheService cacheService;
private final EventPublisher eventPublisher;
private final MeetingService meetingService;
private final TodoService todoService;
private final AiServiceGateway aiServiceGateway;
private final AgendaSectionService agendaSectionService;
private final ParticipantReader participantReader;
@@ -110,14 +98,18 @@ public class MinutesController {
// DTO를 Response 형식으로 변환
List<MinutesListResponse.MinutesItem> minutesList = minutesPage.getContent().stream()
.map(this::convertToMinutesItem)
.map(dto -> convertToMinutesItem(dto, userId))
.collect(Collectors.toList());
// 필터링 적용 (상태별)
// 필터링 적용 (상태별, 참여 유형별, 검색어)
List<MinutesListResponse.MinutesItem> filteredMinutes = minutesList.stream()
.filter(item -> filterByStatus(item, status))
.filter(item -> filterByParticipationType(item, participationType))
.filter(item -> filterBySearch(item, search))
.collect(Collectors.toList());
// 필터링 후 정렬 적용 (프론트엔드 정렬과 일치)
applySorting(filteredMinutes, sortBy, sortDir);
// 통계 계산 (전체 데이터 기준)
MinutesListResponse.Statistics stats = calculateRealStatistics(userId, participationType);
@@ -477,7 +469,8 @@ public class MinutesController {
case "title":
return Sort.by(direction, "title");
case "meeting":
return Sort.by(direction, "createdAt"); // 회의 일시로 정렬 (임시로 생성일시 사용)
// 회의 일시로 정렬 - DB에 meetingDate 필드가 없으므로 createdAt 사용
return Sort.by(direction, "createdAt");
case "modified":
default:
return Sort.by(direction, "lastModifiedAt");
@@ -489,8 +482,21 @@ public class MinutesController {
*/
private MinutesListResponse.Statistics calculateRealStatistics(String userId, String participationType) {
try {
// 전체 회의록 조회 (작성자 기준)
List<Minutes> allMinutes = minutesService.getMinutesByCreator(userId);
// 전체 회의록 조회 (참여자 기준)
List<MinutesDTO> allMinutes = minutesService.getMinutesListByUserId(userId, PageRequest.of(0, Integer.MAX_VALUE)).getContent();
// 참여 유형 필터링
if (participationType != null && !participationType.isEmpty()) {
if ("created".equals(participationType)) {
allMinutes = allMinutes.stream()
.filter(m -> userId.equals(m.getCreatedBy()))
.collect(Collectors.toList());
} else if ("attended".equals(participationType)) {
allMinutes = allMinutes.stream()
.filter(m -> !userId.equals(m.getCreatedBy()))
.collect(Collectors.toList());
}
}
long totalCount = allMinutes.size();
long draftCount = allMinutes.stream()
@@ -515,10 +521,39 @@ public class MinutesController {
}
}
private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO) {
// 완료율 계산
int completionRate = minutesDTO.getTodoCount() > 0 ?
(minutesDTO.getCompletedTodoCount() * 100) / minutesDTO.getTodoCount() : 100;
private MinutesListResponse.MinutesItem convertToMinutesItem(MinutesDTO minutesDTO, String userId) {
// 검증완료율 계산 (작성중 상태일 때는 verificationRate 사용, 확정완료시 100%)
int verificationRate;
if ("DRAFT".equals(minutesDTO.getStatus()) && minutesDTO.getVerificationRate() != null) {
verificationRate = minutesDTO.getVerificationRate();
} else if ("FINALIZED".equals(minutesDTO.getStatus())) {
verificationRate = 100;
} else {
// 기본값 0
verificationRate = 0;
}
// 회의 날짜/시간 추출
LocalDateTime meetingDateTime = minutesDTO.getCreatedAt(); // 임시로 생성일시 사용
String meetingTime = null;
// 실제 회의 정보에서 날짜/시간 추출 시도
try {
var meeting = meetingService.getMeeting(minutesDTO.getMeetingId());
if (meeting.getScheduledAt() != null) {
meetingDateTime = meeting.getScheduledAt();
meetingTime = meeting.getScheduledAt().toLocalTime().toString().substring(0, 5);
} else if (meeting.getStartedAt() != null) {
meetingDateTime = meeting.getStartedAt();
meetingTime = meeting.getStartedAt().toLocalTime().toString().substring(0, 5);
}
} catch (Exception e) {
log.debug("회의 정보 조회 실패, 기본값 사용 - meetingId: {}", minutesDTO.getMeetingId());
meetingTime = meetingDateTime.toLocalTime().toString().substring(0, 5);
}
// 생성자 이름 조회 (임시)
String creatorName = getUserName(minutesDTO.getCreatedBy());
return MinutesListResponse.MinutesItem.builder()
.minutesId(minutesDTO.getMinutesId())
@@ -528,14 +563,16 @@ public class MinutesController {
.version(minutesDTO.getVersion())
.createdAt(minutesDTO.getCreatedAt())
.lastModifiedAt(minutesDTO.getLastModifiedAt())
.meetingDate(minutesDTO.getCreatedAt()) // 임시로 생성일시 사용
.meetingDate(meetingDateTime)
.meetingTime(meetingTime)
.createdBy(minutesDTO.getCreatedBy())
.createdByName(creatorName)
.lastModifiedBy(minutesDTO.getLastModifiedBy())
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 0)
.todoCount(minutesDTO.getTodoCount())
.completedTodoCount(minutesDTO.getCompletedTodoCount())
.completionRate(completionRate)
.isCreatedByUser(true) // 현재는 작성자 기준으로만 조회하므로 true
.verificationRate(verificationRate)
.isCreator(minutesDTO.getCreatedBy().equals(userId)) // 현재 사용자가 생성자인지 확인
.build();
}
@@ -556,10 +593,19 @@ public class MinutesController {
}
/**
* 참여 유형별 필터링 - 현재는 사용하지 않음 (작성자 기준으로만 조회)
* 참여 유형별 필터링
*/
private boolean filterByParticipationType(MinutesListResponse.MinutesItem item, String participationType, String userId) {
// 현재는 작성자 기준으로만 조회하므로 항상 true 반환
private boolean filterByParticipationType(MinutesListResponse.MinutesItem item, String participationType) {
if (participationType == null || participationType.isEmpty()) {
return true; // 필터 미적용시 모두 표시
}
if ("created".equals(participationType)) {
return item.isCreator(); // 사용자가 생성한 회의록만
} else if ("attended".equals(participationType)) {
return !item.isCreator(); // 사용자가 참여만 한 회의록
}
return true;
}
@@ -576,7 +622,7 @@ public class MinutesController {
}
/**
* 정렬 적용
* 정렬 적용 (필터링 후)
*/
private void applySorting(List<MinutesListResponse.MinutesItem> items, String sortBy, String sortDir) {
boolean ascending = "asc".equalsIgnoreCase(sortDir);
@@ -588,78 +634,20 @@ public class MinutesController {
b.getTitle().compareTo(a.getTitle()));
break;
case "meeting":
// 회의 날짜순 정렬 (최근회의순은 desc가 기본)
items.sort((a, b) -> ascending ?
a.getMeetingDate().compareTo(b.getMeetingDate()) :
b.getMeetingDate().compareTo(a.getMeetingDate()));
break;
case "modified":
default:
// 최근수정순 정렬 (desc가 기본)
items.sort((a, b) -> ascending ?
a.getLastModifiedAt().compareTo(b.getLastModifiedAt()) :
b.getLastModifiedAt().compareTo(a.getLastModifiedAt()));
break;
}
}
/**
* 통계 계산
*/
private MinutesListResponse.Statistics calculateStatistics(List<MinutesListResponse.MinutesItem> allItems,
String participationType, String userId) {
List<MinutesListResponse.MinutesItem> filteredItems = allItems.stream()
.filter(item -> filterByParticipationType(item, participationType, userId))
.collect(Collectors.toList());
long totalCount = filteredItems.size();
long draftCount = filteredItems.stream()
.filter(item -> "DRAFT".equals(item.getStatus()))
.count();
long completeCount = filteredItems.stream()
.filter(item -> "FINALIZED".equals(item.getStatus()))
.count();
return MinutesListResponse.Statistics.builder()
.totalCount(totalCount)
.draftCount(draftCount)
.completeCount(completeCount)
.build();
}
/**
* Mock 관련회의록 생성 (프로토타입 기반)
*/
private List<MinutesDetailResponse.RelatedMinutes> createMockRelatedMinutes() {
return List.of(
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-related-001")
.title("AI 기능 개선 회의")
.meetingDate(LocalDateTime.of(2025, 10, 23, 15, 0))
.author("이준호")
.relevancePercentage(92)
.relevanceLevel("HIGH")
.summary("AI 요약 정확도 개선 방안 논의. BERT 모델 도입 및 학습 데이터 확보 계획 수립.")
.build(),
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-related-002")
.title("개발 리소스 계획 회의")
.meetingDate(LocalDateTime.of(2025, 10, 22, 11, 0))
.author("김민준")
.relevancePercentage(88)
.relevanceLevel("MEDIUM")
.summary("Q4 개발 리소스 현황 및 배분 계획. 신규 프로젝트 우선순위 협의.")
.build(),
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-related-003")
.title("경쟁사 분석 회의")
.meetingDate(LocalDateTime.of(2025, 10, 20, 10, 0))
.author("박서연")
.relevancePercentage(78)
.relevanceLevel("MEDIUM")
.summary("경쟁사 A, B, C 분석 결과. 우리의 차별점은 실시간 협업 및 검증 기능.")
.build()
);
}
private MinutesDetailResponse convertToMinutesDetailResponse(MinutesDTO minutesDTO) {
try {
@@ -921,11 +909,6 @@ public class MinutesController {
.build();
}
private int calculateActualDuration(Object meeting) {
// TODO: 실제 회의 시간 계산 로직 구현
// Meeting 객체에서 startedAt, endedAt을 사용하여 계산
return 90;
}
private MinutesDetailResponse.AgendaInfo convertToAgendaInfo(Object section) {
if (!(section instanceof MinutesSection)) {
@@ -959,38 +942,6 @@ public class MinutesController {
private MinutesDetailResponse.SimpleTodo convertToSimpleTodo(Object todo) {
if (!(todo instanceof Todo)) {
log.warn("Todo가 아닌 객체가 전달됨: {}", todo.getClass().getSimpleName());
return MinutesDetailResponse.SimpleTodo.builder()
.todoId("unknown-todo")
.title("변환 실패 Todo")
.assigneeName("알 수 없음")
.status("PENDING")
.priority("LOW")
.dueDate(LocalDateTime.now().plusDays(7))
.dueDayStatus("D-7")
.build();
}
Todo todoEntity = (Todo) todo;
// 담당자 이름 조회 (현재는 기본값 사용, 실제로는 User 서비스에서 조회 필요)
String assigneeName = getAssigneeName(todoEntity.getAssigneeId());
// 마감일 상태 계산
String dueDayStatus = calculateDueDayStatus(todoEntity.getDueDate(), todoEntity.getStatus());
return MinutesDetailResponse.SimpleTodo.builder()
.todoId(todoEntity.getTodoId())
.title(todoEntity.getTitle() != null ? todoEntity.getTitle() : "제목 없음")
.assigneeName(assigneeName)
.status(todoEntity.getStatus() != null ? todoEntity.getStatus() : "PENDING")
.priority(todoEntity.getPriority() != null ? todoEntity.getPriority() : "MEDIUM")
.dueDate(todoEntity.getDueDate() != null ? todoEntity.getDueDate().atStartOfDay() : null)
.dueDayStatus(dueDayStatus)
.build();
}
private List<MinutesDetailResponse.KeyPoint> extractKeyPoints(List<MinutesDetailResponse.AgendaInfo> agendas) {
// 안건별 AI 요약에서 핵심내용 추출
@@ -1014,51 +965,6 @@ public class MinutesController {
}
/**
* 담당자 이름 조회 (실제로는 User 서비스에서 조회 필요)
*/
private String getAssigneeName(String assigneeId) {
if (assigneeId == null) {
return "미지정";
}
// TODO: 실제 User 서비스에서 사용자 정보 조회
// 현재는 간단한 매핑 사용
switch (assigneeId) {
case "user1":
return "김민준";
case "user2":
return "박서연";
case "user3":
return "이준호";
default:
return "사용자" + assigneeId;
}
}
/**
* 마감일 상태 계산
*/
private String calculateDueDayStatus(LocalDate dueDate, String status) {
if (dueDate == null) {
return "마감일 없음";
}
if ("COMPLETED".equals(status)) {
return "완료";
}
LocalDate today = LocalDate.now();
long daysDiff = ChronoUnit.DAYS.between(today, dueDate);
if (daysDiff < 0) {
return "D+" + Math.abs(daysDiff); // 마감일 지남
} else if (daysDiff == 0) {
return "D-Day";
} else {
return "D-" + daysDiff;
}
}
private List<String> extractKeywords(List<MinutesDetailResponse.AgendaInfo> agendas) {
// TODO: AI를 통한 키워드 추출 로직 구현
@@ -1231,41 +1137,6 @@ public class MinutesController {
.collect(Collectors.toList());
}
/**
* AI 분석 요청 이벤트 발행
*/
private void publishAiAnalysisRequest(MinutesDTO minutesDTO, String requesterId, String requesterName) {
try {
// 회의 메타정보 구성
MinutesAnalysisRequestEvent.MeetingMeta meetingMeta = MinutesAnalysisRequestEvent.MeetingMeta.builder()
.title(minutesDTO.getMeetingTitle())
.meetingDate(minutesDTO.getCreatedAt())
.participantCount(minutesDTO.getParticipantCount() != null ? minutesDTO.getParticipantCount() : 1)
.durationMinutes(90) // 기본값
.organizerId(minutesDTO.getCreatedBy())
.participantIds(new String[]{requesterId}) // 기본값
.build();
// AI 분석 요청 이벤트 생성
MinutesAnalysisRequestEvent requestEvent = MinutesAnalysisRequestEvent.create(
minutesDTO.getMinutesId(),
minutesDTO.getMeetingId(),
requesterId,
requesterName,
extractContentForAiAnalysis(minutesDTO),
meetingMeta
);
// 이벤트 발행
eventPublisher.publishMinutesAnalysisRequest(requestEvent);
log.info("AI 분석 요청 이벤트 발행 완료 - minutesId: {}, eventId: {}",
minutesDTO.getMinutesId(), requestEvent.getEventId());
} catch (Exception e) {
log.error("AI 분석 요청 이벤트 발행 실패 - minutesId: {}", minutesDTO.getMinutesId(), e);
}
}
/**
* 실제 회의 시간 계산
@@ -46,12 +46,32 @@ public class MinutesListResponse {
private LocalDateTime createdAt;
private LocalDateTime lastModifiedAt;
private LocalDateTime meetingDate; // 회의 일시
private String meetingTime; // 회의 시간 (HH:mm 형식)
private String createdBy;
private String createdByName; // 생성자 이름
private String lastModifiedBy;
private int participantCount; // 참석자 수
private int todoCount;
private int completedTodoCount;
private int completionRate; // 검증완료율
private boolean isCreatedByUser; // 사용자가 생성한 회의록 여부
private int verificationRate; // 검증완료율 (프로토타입과 일치)
private boolean isCreator; // 현재 사용자가 생성자인지 여부
// 편의 메서드 추가
public String getFormattedDate() {
if (meetingDate != null) {
return meetingDate.toLocalDate().toString();
}
return "";
}
public String getFormattedTime() {
if (meetingTime != null) {
return meetingTime;
}
if (meetingDate != null) {
return meetingDate.toLocalTime().toString().substring(0, 5);
}
return "";
}
}
}
@@ -18,8 +18,10 @@ import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -44,7 +46,7 @@ public class DashboardGateway implements DashboardReader {
// 1. 다가오는 회의 목록 조회 (향후 30일, 최대 10개)
List<Meeting> upcomingMeetings = getUpcomingMeetings(userId);
// 2. 최근 회의록 목록 조회 (최근 7일, 최대 10개)
// 2. 최근 회의록 목록 조회 (최근 30일, 최대 4개)
List<Minutes> recentMinutes = getRecentMinutes(userId);
// 3. 통계 정보 계산 (최근 30일 기준)
@@ -73,11 +75,32 @@ public class DashboardGateway implements DashboardReader {
LocalDateTime startTime = calculateStartTime(period);
LocalDateTime endTime = LocalDateTime.now();
// 1. 기간 내 다가오는 회의 목록 조회
List<Meeting> upcomingMeetings = getUpcomingMeetingsByPeriod(userId, startTime, endTime);
// 1. 기간 내 다가오는 회의 목록 조회 (UFR-USER-020 기준 적용)
List<Meeting> meetings = getUpcomingMeetingsByPeriod(userId, startTime, endTime);
// 2. 기간 내 최근 회의록 목록 조회
List<Minutes> recentMinutes = getRecentMinutesByPeriod(userId, startTime, endTime);
// 회의록 생성 여부 확인을 위한 맵 생성
Set<String> meetingsWithMinutes = new HashSet<>();
minutesJpaRepository.findAll().stream()
.forEach(minutes -> meetingsWithMinutes.add(minutes.getMeetingId()));
// 정렬: 1) 회의록 미생성 우선, 2) 빠른 일시 순
List<Meeting> upcomingMeetings = meetings.stream()
.sorted((m1, m2) -> {
boolean m1HasMinutes = meetingsWithMinutes.contains(m1.getMeetingId());
boolean m2HasMinutes = meetingsWithMinutes.contains(m2.getMeetingId());
if (!m1HasMinutes && m2HasMinutes) return -1;
if (m1HasMinutes && !m2HasMinutes) return 1;
return m1.getScheduledAt().compareTo(m2.getScheduledAt());
})
.limit(3) // 최대 3개
.collect(Collectors.toList());
// 2. 기간 내 최근 회의록 목록 조회 (최대 4개, 최신순)
List<Minutes> recentMinutes = getRecentMinutesByPeriod(userId, startTime, endTime).stream()
.limit(4)
.collect(Collectors.toList());
// 3. 기간별 통계 정보 계산
Dashboard.Statistics statistics = calculateStatisticsByPeriod(userId, startTime, endTime);
@@ -96,93 +119,145 @@ public class DashboardGateway implements DashboardReader {
}
/**
* 다가오는 회의 목록 조회
* 다가오는 회의 목록 조회 (유저스토리 UFR-USER-020 기준)
* - 최대 3개
* - 회의록 미생성 우선
* - 빠른 일시 순 (회의 시작 시간 기준)
*/
private List<Meeting> getUpcomingMeetings(String userId) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime endTime = now.plusDays(30); // 향후 30일
// 과거 7일부터 향후 30일까지의 회의를 조회 (진행중/완료된 최근 회의 포함)
LocalDateTime startTime = now.minusDays(7);
LocalDateTime endTime = now.plusDays(30);
return getUpcomingMeetingsByPeriod(userId, now, endTime);
List<Meeting> meetings = getUpcomingMeetingsByPeriod(userId, startTime, endTime);
// 회의록 생성 여부 확인을 위한 맵 생성
Set<String> meetingsWithMinutes = new HashSet<>();
minutesJpaRepository.findAll().stream()
.forEach(minutes -> meetingsWithMinutes.add(minutes.getMeetingId()));
// 정렬: 1) 회의록 미생성 우선, 2) 빠른 일시 순 (오름차순)
return meetings.stream()
.sorted((m1, m2) -> {
boolean m1HasMinutes = meetingsWithMinutes.contains(m1.getMeetingId());
boolean m2HasMinutes = meetingsWithMinutes.contains(m2.getMeetingId());
// 회의록 미생성이 우선
if (!m1HasMinutes && m2HasMinutes) return -1;
if (m1HasMinutes && !m2HasMinutes) return 1;
// 둘 다 같은 상태면 시간순 (빠른 일시 우선)
return m1.getScheduledAt().compareTo(m2.getScheduledAt());
})
.limit(3) // 최대 3개만
.collect(Collectors.toList());
}
/**
* 기간별 다가오는 회의 목록 조회
* SCHEDULED, IN_PROGRESS, COMPLETED 상태의 회의를 모두 포함
*/
private List<Meeting> getUpcomingMeetingsByPeriod(String userId, LocalDateTime startTime, LocalDateTime endTime) {
Set<String> userMeetingIds = new HashSet<>();
// 주최자로 참여하는 예정/진행중 회의 조회
// 주최자로 참여하는 모든 상태의 회의 조회 (CANCELLED 제외)
List<MeetingEntity> organizerMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> userId.equals(m.getOrganizerId()))
.filter(m -> "SCHEDULED".equals(m.getStatus()) || "IN_PROGRESS".equals(m.getStatus()))
.filter(m -> !"CANCELLED".equals(m.getStatus())) // CANCELLED만 제외
.toList();
organizerMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId()));
// 참석자로 참여하는 예정/진행중 회의 조회
// 참석자로 참여하는 모든 상태의 회의 조회 (CANCELLED 제외)
List<String> participantMeetingIds = meetingParticipantJpaRepository.findByUserId(userId).stream()
.map(p -> p.getMeetingId())
.toList();
List<MeetingEntity> participantMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> participantMeetingIds.contains(m.getMeetingId()))
.filter(m -> "SCHEDULED".equals(m.getStatus()) || "IN_PROGRESS".equals(m.getStatus()))
.filter(m -> !"CANCELLED".equals(m.getStatus())) // CANCELLED만 제외
.toList();
participantMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId()));
// 중복 제거된 회의 목록을 시간순 정렬하여 최대 10개만 반환
return meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
// 중복 제거된 회의 목록을 시간순 정렬하여 반환
// 과거 회의는 최신순(내림차순), 미래 회의는 오래된순(오름차순)으로 정렬
LocalDateTime now = LocalDateTime.now();
List<Meeting> meetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> userMeetingIds.contains(m.getMeetingId()))
.filter(m -> "SCHEDULED".equals(m.getStatus()) || "IN_PROGRESS".equals(m.getStatus()))
.sorted((m1, m2) -> m1.getScheduledAt().compareTo(m2.getScheduledAt()))
.limit(10)
.filter(m -> !"CANCELLED".equals(m.getStatus())) // CANCELLED만 제외
.sorted((m1, m2) -> {
boolean m1IsPast = m1.getScheduledAt().isBefore(now);
boolean m2IsPast = m2.getScheduledAt().isBefore(now);
if (m1IsPast && m2IsPast) {
// 둘 다 과거 회의면 최신순(내림차순)
return m2.getScheduledAt().compareTo(m1.getScheduledAt());
} else if (!m1IsPast && !m2IsPast) {
// 둘 다 미래 회의면 오래된순(오름차순)
return m1.getScheduledAt().compareTo(m2.getScheduledAt());
} else {
// 하나는 과거, 하나는 미래면 미래 회의를 먼저
return m1IsPast ? 1 : -1;
}
})
// limit 제거 - 상위 메서드에서 필터링
.map(MeetingEntity::toDomain)
.collect(Collectors.toList());
log.debug("조회된 회의 목록 - userId: {}, 총 {}개 (SCHEDULED: {}, IN_PROGRESS: {}, COMPLETED: {})",
userId,
meetings.size(),
meetings.stream().filter(m -> "SCHEDULED".equals(m.getStatus())).count(),
meetings.stream().filter(m -> "IN_PROGRESS".equals(m.getStatus())).count(),
meetings.stream().filter(m -> "COMPLETED".equals(m.getStatus())).count()
);
return meetings;
}
/**
* 최근 회의록 목록 조회
* 최근 회의록 목록 조회 (유저스토리 UFR-USER-020 기준)
* - 최대 4개
* - 최신순 (최근 수정/생성 시간 기준)
*/
private List<Minutes> getRecentMinutes(String userId) {
LocalDateTime startTime = LocalDateTime.now().minusDays(7);
return getRecentMinutesByPeriod(userId, startTime, LocalDateTime.now());
LocalDateTime startTime = LocalDateTime.now().minusDays(30); // 더 넓은 범위에서 조회
List<Minutes> minutes = getRecentMinutesByPeriod(userId, startTime, LocalDateTime.now());
// 이미 getRecentMinutesByPeriod에서 최신순으로 정렬되어 있으므로
// 최대 4개만 반환
return minutes.stream()
.limit(4)
.collect(Collectors.toList());
}
/**
* 기간별 최근 회의록 목록 조회
*/
private List<Minutes> getRecentMinutesByPeriod(String userId, LocalDateTime startTime, LocalDateTime endTime) {
Set<String> userMinutesIds = new HashSet<>();
log.debug("회의록 조회 시작 - userId: {}, startTime: {}, endTime: {}", userId, startTime, endTime);
// 작성자로 참여한 회의록 조회
List<MinutesEntity> createdMinutes = minutesJpaRepository.findByCreatedBy(userId).stream()
.filter(m -> m.getCreatedAt().isAfter(startTime) && m.getCreatedAt().isBefore(endTime))
.toList();
createdMinutes.forEach(m -> userMinutesIds.add(m.getMinutesId()));
// 참석한 회의의 회의록 조회
List<String> participantMeetingIds = meetingParticipantJpaRepository.findByUserId(userId).stream()
.map(p -> p.getMeetingId())
.toList();
List<MinutesEntity> participatedMinutes = minutesJpaRepository.findAll().stream()
.filter(m -> participantMeetingIds.contains(m.getMeetingId()))
.filter(m -> m.getCreatedAt().isAfter(startTime) && m.getCreatedAt().isBefore(endTime))
.toList();
participatedMinutes.forEach(m -> userMinutesIds.add(m.getMinutesId()));
// 중복 제거 후 최종 수정 시간순 정렬하여 최대 10개만 반환
return minutesJpaRepository.findAll().stream()
.filter(m -> userMinutesIds.contains(m.getMinutesId()))
// 사용자가 작성한 회의록 조회 (createdBy = userId)
List<MinutesEntity> userMinutes = minutesJpaRepository.findByCreatedBy(userId).stream()
.filter(m -> m.getCreatedAt() != null &&
m.getCreatedAt().isAfter(startTime) &&
m.getCreatedAt().isBefore(endTime))
.sorted((m1, m2) -> {
LocalDateTime time1 = m1.getUpdatedAt() != null ? m1.getUpdatedAt() : m1.getCreatedAt();
LocalDateTime time2 = m2.getUpdatedAt() != null ? m2.getUpdatedAt() : m2.getCreatedAt();
return time2.compareTo(time1); // 최신순
})
.limit(10)
.toList();
log.debug("조회된 회의록 수: {}", userMinutes.size());
userMinutes.forEach(m -> {
log.debug(" - minutesId: {}, meetingId: {}, title: {}, createdBy: {}, createdAt: {}",
m.getMinutesId(), m.getMeetingId(), m.getTitle(), m.getCreatedBy(), m.getCreatedAt());
});
return userMinutes.stream()
.map(MinutesEntity::toDomain)
.collect(Collectors.toList());
}
@@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@@ -75,9 +76,32 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
@Override
public Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId) {
log.debug("회의 ID로 AI 통합 회의록 조회: {}", meetingId);
return minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId)
List<MinutesEntity> consolidatedMinutes = minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId);
if (consolidatedMinutes.isEmpty()) {
return Optional.empty();
}
// 여러 개가 있을 경우 가장 최신 것을 반환 (updatedAt 또는 createdAt 기준)
return consolidatedMinutes.stream()
.sorted((m1, m2) -> {
LocalDateTime time1 = m1.getUpdatedAt() != null ? m1.getUpdatedAt() : m1.getCreatedAt();
LocalDateTime time2 = m2.getUpdatedAt() != null ? m2.getUpdatedAt() : m2.getCreatedAt();
return time2.compareTo(time1); // 최신순
})
.findFirst()
.map(MinutesEntity::toDomain);
}
@Override
public List<Minutes> findByParticipantUserId(String userId) {
log.debug("사용자가 참여한 회의의 회의록 조회: {}", userId);
// 사용자가 생성한 회의록과 사용자가 참여한 회의의 회의록을 모두 조회
// 현재는 생성자 기준으로만 조회 (추후 참석자 테이블과 조인하여 구현 필요)
return minutesJpaRepository.findByCreatedByOrParticipantUserId(userId).stream()
.map(MinutesEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Minutes save(Minutes minutes) {
@@ -57,6 +57,13 @@ public class MinutesEntity extends BaseTimeEntity {
@Column(name = "finalized_at")
private LocalDateTime finalizedAt;
/**
* 결정사항 업데이트
*/
public void updateDecisions(String decisions) {
this.decisions = decisions;
}
public Minutes toDomain() {
return Minutes.builder()
.minutesId(this.minutesId)
@@ -50,7 +50,20 @@ public interface MinutesJpaRepository extends JpaRepository<MinutesEntity, Strin
List<MinutesEntity> findByMeetingIdAndUserIdIsNotNull(String meetingId);
/**
* 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL)
* 회의 ID로 AI 통합 회의록 목록 조회 (user_id IS NULL)
*/
Optional<MinutesEntity> findByMeetingIdAndUserIdIsNull(String meetingId);
List<MinutesEntity> findByMeetingIdAndUserIdIsNull(String meetingId);
/**
* 사용자가 생성했거나 참여한 회의의 회의록 조회
* 현재는 생성자 기준으로만 조회 (추후 참석자 테이블과 조인 필요)
*
* @param userId 사용자 ID
* @return 회의록 목록
*/
default List<MinutesEntity> findByCreatedByOrParticipantUserId(String userId) {
// TODO: 참석자 테이블(participants)과 조인하여 참여한 회의의 회의록도 조회하도록 구현 필요
// 현재는 임시로 생성자 기준으로만 조회
return findByCreatedBy(userId);
}
}
+1 -1
View File
@@ -156,4 +156,4 @@ azure:
ai:
service:
url: ${AI_SERVICE_URL:http://localhost:8087}
timeout: ${AI_SERVICE_TIMEOUT:30000}
timeout: ${AI_SERVICE_TIMEOUT:60000}
@@ -0,0 +1,274 @@
-- ========================================
-- AI 회의록 요약 테스트 데이터
-- ========================================
-- 목적: Minutes.decisions 및 AgendaSection 저장 검증
-- 회의: 2025년 신제품 런칭 전략 회의
-- 참석자: 3명 (마케팅팀장, 개발팀장, 디자인팀장)
-- 안건: 3개 (타겟 고객 설정, 핵심 기능 정의, 런칭 일정)
-- ========================================
-- 1. 회의 생성
-- ========================================
INSERT INTO meetings (
meeting_id,
title,
purpose,
description,
location,
scheduled_at,
end_time,
started_at,
ended_at,
status,
organizer_id,
template_id,
created_at,
updated_at
) VALUES (
'ai_test_meeting',
'2025년 신제품 런칭 전략 회의',
'신제품 출시를 위한 전략 수립 및 일정 협의',
'타겟 고객층 정의, 핵심 기능 결정, 출시 일정 확정',
'본사 4층 회의실',
'2025-01-15 14:00:00',
'2025-01-15 16:00:00',
'2025-01-15 14:05:00',
NULL, -- 아직 종료 안됨
'IN_PROGRESS',
'user_organizer',
'template_general',
NOW(),
NOW()
);
-- ========================================
-- 2. 참석자별 회의록 생성 (user_id NOT NULL)
-- ========================================
-- 2-1. 마케팅팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_marketing',
'ai_test_meeting',
'user_marketing',
'2025년 신제품 런칭 전략 회의 - 마케팅팀장',
'DRAFT',
1,
'user_marketing_head',
NOW(),
NOW()
);
-- 2-2. 개발팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_dev',
'ai_test_meeting',
'user_dev',
'2025년 신제품 런칭 전략 회의 - 개발팀장',
'DRAFT',
1,
'user_dev',
NOW(),
NOW()
);
-- 2-3. 디자인팀장 회의록
INSERT INTO minutes (
minutes_id,
meeting_id,
user_id,
title,
status,
version,
created_by,
created_at,
updated_at
) VALUES (
'ai_test_minutes_design',
'ai_test_meeting',
'user_design',
'2025년 신제품 런칭 전략 회의 - 디자인팀장',
'DRAFT',
1,
'user_design',
NOW(),
NOW()
);
-- ========================================
-- 3. 마케팅팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_marketing',
'ai_test_minutes_marketing',
'MEMO',
'마케팅팀장 메모',
'【안건 1: 타겟 고객 설정】
- 주요 타겟: 20-30대 직장인, 특히 재택근무자와 1인 가구
- 김마케팅팀장 의견: 최근 시장조사 결과 20대 후반~30대 초반 직장인들의 스마트홈 제품 관심도가 가장 높았습니다
- 부타겟: 40대 맞벌이 부부
- 결정사항: 1차 타겟을 20-30대 직장인으로 확정, SNS 마케팅 집중
【안건 2: 핵심 기능 정의】
- 음성인식 AI 비서 기능은 필수로 포함하기로 결정
- 김마케팅팀장: 경쟁사 제품 대비 차별화를 위해 멀티 디바이스 연동 기능이 핵심입니다
- 스마트폰 앱 연동은 1차 출시에 포함
- 보류: IoT 센서 연동은 2차 업데이트로 연기 (기술 검증 필요)
- 담당: 이개발팀장이 AI 음성인식 기술 파트너 컨택 예정
【안건 3: 런칭 일정】
- 목표 출시일: 2025년 6월 1일
- 마케팅 캠페인은 5월 초부터 티저 광고 시작
- 김마케팅팀장: 5월 첫째 주에 인플루언서 협업 콘텐츠 제작 필요
- 결정: 박디자인팀장이 3월 말까지 프로토타입 디자인 완료
- 할일: 김마케팅팀장이 2월 중 타겟 인플루언서 리스트 작성',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 4. 개발팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_dev',
'ai_test_minutes_dev',
'MEMO',
'개발팀장 메모',
'【안건 1: 타겟 고객 설정】
- 이개발팀장: 타겟 연령층이 20-30대라면 모바일 앱 UX가 정말 중요합니다
- 20-30대 직장인 타겟 동의
- 기술적으로 이 연령층은 새로운 기술 수용도가 높아서 AI 기능 적극 활용 예상
【안건 2: 핵심 기능 정의】
- 음성인식 AI: OpenAI Whisper 또는 Google Speech API 검토 중
- 이개발팀장: 멀티 디바이스 연동을 위해서는 클라우드 기반 아키텍처 필수입니다
- 결정: 음성인식 AI는 한국어 특화 모델 사용하기로 확정
- 보류사항: IoT 센서는 기술 검증 후 2차 업데이트 (전력 소비 이슈)
- 할일: 이개발팀장이 1월 말까지 AI 음성인식 기술 파트너 미팅 잡기
- 할일: 최개발자가 2월 중 클라우드 인프라 설계서 작성
【안건 3: 런칭 일정】
- 6월 1일 출시 목표는 타이트하지만 가능할 것으로 판단
- 개발 일정: 2월 프로토타입, 3월 알파, 4월 베타, 5월 최종 QA
- 이개발팀장: 베타 테스트는 최소 3주 필요, 5월 초에 시작해야 합니다
- 결정: 개발팀은 4월 중순까지 베타 버전 완성
- 보류: 다국어 지원은 일단 한국어만, 영어는 추후 검토',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 5. 디자인팀장 회의록 섹션 (MEMO 타입)
-- ========================================
INSERT INTO minutes_sections (
id,
minutes_id,
type,
title,
content,
"order",
verified,
locked,
locked_by
) VALUES (
'ai_test_section_design',
'ai_test_minutes_design',
'MEMO',
'디자인팀장 메모',
'【안건 1: 타겟 고객 설정】
- 20-30대 타겟에 동의, 디자인 방향도 MZ세대 취향 고려 필요
- 박디자인팀장: "미니멀하고 감각적인 디자인으로 차별화해야 합니다"
- 레퍼런스: 애플 HomePod, 구글 Nest Hub
- 색상은 화이트, 그레이, 블랙 3종으로 결정
【안건 2: 핵심 기능 정의】
- 음성인식 피드백 UI가 중요 - LED 링 또는 디스플레이 활용
- 박디자인팀장: "사용자가 AI가 듣고 있다는 걸 직관적으로 알 수 있어야 합니다"
- 멀티 디바이스 연동 시 화면 전환 애니메이션 필요
- 결정: 터치 인터페이스와 음성 인터페이스 병행
- 할일: 김디자이너가 2월 말까지 UI/UX 목업 완성
【안건 3: 런칭 일정】
- 6월 출시 위해 3월 말 프로토타입 디자인 완료 확약
- 디자인 일정: 2월 컨셉 확정, 3월 프로토타입, 4월 최종 디자인
- 박디자인팀장: "패키지 디자인도 프리미엄 느낌으로 가야 합니다"
- 결정: 제품 패키지는 친환경 소재 사용
- 할일: 박디자인팀장이 3월 말까지 프로토타입 디자인 완료
- 할일: 박디자인팀장이 4월 중 패키지 디자인 최종안 제출',
1,
FALSE,
FALSE,
NULL
);
-- ========================================
-- 검증 쿼리
-- ========================================
-- 회의 확인
-- SELECT * FROM meetings WHERE meeting_id = 'ai_test_meeting_001';
-- 참석자별 회의록 확인
-- SELECT minutes_id, user_id, title FROM minutes WHERE meeting_id = 'ai_test_meeting_001';
-- 회의록 섹션 확인
-- SELECT id, minutes_id, title, LEFT(content, 100) as content_preview
-- FROM minutes_sections
-- WHERE minutes_id LIKE 'ai_test_minutes_%';
-- ========================================
-- 테스트 실행 가이드
-- ========================================
-- 1. 이 SQL 실행하여 테스트 데이터 생성
-- 2. POST /api/meetings/ai_test_meeting_001/end 호출
-- 3. 검증:
-- - minutes 테이블에 userId=NULL인 통합 회의록 생성 확인
-- - minutes.decisions 필드에 전체 결정사항 저장 확인
-- - agenda_sections 테이블에 3개 안건 저장 확인
-- - agenda_sections.summary에 논의+결정 내용 저장 확인