Chore: 회의록 상세 조회 API 실제 데이터 연동

This commit is contained in:
cyjadela
2025-10-28 22:27:31 +09:00
parent 6c005ec923
commit d003e801bb
301 changed files with 12866 additions and 772 deletions
@@ -0,0 +1,82 @@
package com.unicorn.hgzero.meeting.biz.domain;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
/**
* 회의 안건 섹션 도메인 모델
* agenda_sections 테이블과 매핑
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgendaSection {
/**
* 안건 ID
*/
private String id;
/**
* 회의록 ID
*/
private String minutesId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 안건 번호
*/
private Integer agendaNumber;
/**
* 안건 제목
*/
private String agendaTitle;
/**
* AI 요약 (짧은 버전)
*/
private String aiSummaryShort;
/**
* 논의사항 (JSON 형태로 저장)
*/
private String discussions;
/**
* 결정사항 (JSON 형태로 저장)
*/
private String decisions;
/**
* 의견 (JSON 형태로 저장)
*/
private String opinions;
/**
* 보류사항 (JSON 형태로 저장)
*/
private String pendingItems;
/**
* 할일 목록 (JSON 형태로 저장)
*/
private String todos;
/**
* 생성일시
*/
private LocalDateTime createdAt;
/**
* 수정일시
*/
private LocalDateTime updatedAt;
}
@@ -0,0 +1,91 @@
package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.AgendaSectionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
/**
* 회의 안건 섹션 서비스
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AgendaSectionService {
private final AgendaSectionRepository agendaSectionRepository;
/**
* 회의록 ID로 안건 섹션 목록 조회
* @param minutesId 회의록 ID
* @return 안건 섹션 도메인 목록
*/
@Transactional(readOnly = true)
public List<AgendaSection> getAgendaSectionsByMinutesId(String minutesId) {
log.info("안건 섹션 목록 조회 - minutesId: {}", minutesId);
List<AgendaSectionEntity> entities = agendaSectionRepository.findByMinutesIdOrderByAgendaNumber(minutesId);
return entities.stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
/**
* 회의 ID로 안건 섹션 목록 조회
* @param meetingId 회의 ID
* @return 안건 섹션 도메인 목록
*/
@Transactional(readOnly = true)
public List<AgendaSection> getAgendaSectionsByMeetingId(String meetingId) {
log.info("안건 섹션 목록 조회 - meetingId: {}", meetingId);
List<AgendaSectionEntity> entities = agendaSectionRepository.findByMeetingIdOrderByAgendaNumber(meetingId);
return entities.stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
/**
* 안건 섹션 저장
* @param agendaSection 안건 섹션 도메인
* @return 저장된 안건 섹션
*/
@Transactional
public AgendaSection saveAgendaSection(AgendaSection agendaSection) {
log.info("안건 섹션 저장 - minutesId: {}, agendaTitle: {}",
agendaSection.getMinutesId(), agendaSection.getAgendaTitle());
AgendaSectionEntity entity = AgendaSectionEntity.fromDomain(agendaSection);
AgendaSectionEntity savedEntity = agendaSectionRepository.save(entity);
return savedEntity.toDomain();
}
/**
* 안건 섹션 목록 저장
* @param agendaSections 안건 섹션 도메인 목록
* @return 저장된 안건 섹션 목록
*/
@Transactional
public List<AgendaSection> saveAgendaSections(List<AgendaSection> agendaSections) {
log.info("안건 섹션 목록 저장 - 개수: {}", agendaSections.size());
List<AgendaSectionEntity> entities = agendaSections.stream()
.map(AgendaSectionEntity::fromDomain)
.collect(Collectors.toList());
List<AgendaSectionEntity> savedEntities = agendaSectionRepository.saveAll(entities);
return savedEntities.stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
}
@@ -6,11 +6,13 @@ 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.response.MinutesDetailResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MinutesListResponse;
@@ -19,6 +21,7 @@ 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.biz.usecase.out.ParticipantReader;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
@@ -38,6 +41,7 @@ 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;
@@ -64,6 +68,9 @@ public class MinutesController {
private final MeetingService meetingService;
private final TodoService todoService;
private final AiServiceGateway aiServiceGateway;
private final AgendaSectionService agendaSectionService;
private final ParticipantReader participantReader;
private final com.unicorn.hgzero.meeting.biz.usecase.out.TodoReader todoReader;
/**
* 회의록 목록 조회
@@ -428,144 +435,6 @@ public class MinutesController {
.isCreatedByUser(true) // 현재는 작성자 기준으로만 조회하므로 true
.build();
}
/**
* Mock 데이터 생성 (프론트엔드 테스트용)
*/
private List<MinutesListResponse.MinutesItem> createMockMinutesList(String userId) {
List<MinutesListResponse.MinutesItem> mockData = List.of(
// 사용자가 생성한 회의록들
MinutesListResponse.MinutesItem.builder()
.minutesId("minutes-001")
.title("2024년 1분기 성과리뷰 회의록")
.meetingTitle("2024년 1분기 성과리뷰")
.status("FINALIZED")
.version(3)
.createdAt(LocalDateTime.of(2024, 3, 15, 14, 0))
.lastModifiedAt(LocalDateTime.of(2024, 3, 15, 16, 30))
.meetingDate(LocalDateTime.of(2024, 3, 15, 14, 0))
.createdBy(userId)
.lastModifiedBy(userId)
.participantCount(8)
.todoCount(5)
.completedTodoCount(5)
.completionRate(100)
.isCreatedByUser(true)
.build(),
MinutesListResponse.MinutesItem.builder()
.minutesId("minutes-002")
.title("신규 프로젝트 킥오프 회의록")
.meetingTitle("신규 프로젝트 킥오프")
.status("DRAFT")
.version(1)
.createdAt(LocalDateTime.of(2024, 3, 20, 10, 0))
.lastModifiedAt(LocalDateTime.of(2024, 3, 20, 11, 45))
.meetingDate(LocalDateTime.of(2024, 3, 20, 10, 0))
.createdBy(userId)
.lastModifiedBy("user-002")
.participantCount(6)
.todoCount(8)
.completedTodoCount(3)
.completionRate(75)
.isCreatedByUser(true)
.build(),
// 사용자가 참석한 회의록들
MinutesListResponse.MinutesItem.builder()
.minutesId("minutes-003")
.title("마케팅 전략 회의록")
.meetingTitle("마케팅 전략 논의")
.status("FINALIZED")
.version(2)
.createdAt(LocalDateTime.of(2024, 3, 18, 15, 0))
.lastModifiedAt(LocalDateTime.of(2024, 3, 18, 17, 0))
.meetingDate(LocalDateTime.of(2024, 3, 18, 15, 0))
.createdBy("user-003")
.lastModifiedBy("user-003")
.participantCount(5)
.todoCount(4)
.completedTodoCount(4)
.completionRate(100)
.isCreatedByUser(false)
.build(),
MinutesListResponse.MinutesItem.builder()
.minutesId("minutes-004")
.title("기술 아키텍처 리뷰 회의록")
.meetingTitle("기술 아키텍처 리뷰")
.status("DRAFT")
.version(1)
.createdAt(LocalDateTime.of(2024, 3, 22, 9, 0))
.lastModifiedAt(LocalDateTime.of(2024, 3, 22, 10, 30))
.meetingDate(LocalDateTime.of(2024, 3, 22, 9, 0))
.createdBy("user-004")
.lastModifiedBy("user-004")
.participantCount(7)
.todoCount(6)
.completedTodoCount(2)
.completionRate(60)
.isCreatedByUser(false)
.build(),
MinutesListResponse.MinutesItem.builder()
.minutesId("minutes-005")
.title("주간 스프린트 회고 회의록")
.meetingTitle("주간 스프린트 회고")
.status("FINALIZED")
.version(1)
.createdAt(LocalDateTime.of(2024, 3, 25, 16, 0))
.lastModifiedAt(LocalDateTime.of(2024, 3, 25, 17, 0))
.meetingDate(LocalDateTime.of(2024, 3, 25, 16, 0))
.createdBy("user-005")
.lastModifiedBy("user-005")
.participantCount(4)
.todoCount(3)
.completedTodoCount(3)
.completionRate(100)
.isCreatedByUser(false)
.build(),
// 추가 더미 데이터들
MinutesListResponse.MinutesItem.builder()
.minutesId("minutes-006")
.title("고객 피드백 분석 회의록")
.meetingTitle("고객 피드백 분석")
.status("DRAFT")
.version(2)
.createdAt(LocalDateTime.of(2024, 3, 28, 14, 0))
.lastModifiedAt(LocalDateTime.of(2024, 3, 28, 15, 20))
.meetingDate(LocalDateTime.of(2024, 3, 28, 14, 0))
.createdBy(userId)
.lastModifiedBy(userId)
.participantCount(5)
.todoCount(7)
.completedTodoCount(4)
.completionRate(85)
.isCreatedByUser(true)
.build(),
MinutesListResponse.MinutesItem.builder()
.minutesId("minutes-007")
.title("보안 정책 수립 회의록")
.meetingTitle("보안 정책 수립")
.status("FINALIZED")
.version(1)
.createdAt(LocalDateTime.of(2024, 3, 12, 10, 0))
.lastModifiedAt(LocalDateTime.of(2024, 3, 12, 12, 0))
.meetingDate(LocalDateTime.of(2024, 3, 12, 10, 0))
.createdBy("user-006")
.lastModifiedBy("user-006")
.participantCount(6)
.todoCount(4)
.completedTodoCount(4)
.completionRate(100)
.isCreatedByUser(false)
.build()
);
return mockData;
}
/**
* 상태별 필터링
@@ -653,179 +522,6 @@ public class MinutesController {
.build();
}
/**
* Mock 회의록 상세 데이터 생성 (프로토타입 기반 - 대시보드/회의록 탭 구조)
*/
private MinutesDetailResponse createMockMinutesDetail(String minutesId, String userId) {
return MinutesDetailResponse.builder()
.minutesId(minutesId)
.title("2025년 1분기 제품 기획 회의록")
.memo("본 회의는 AI 기반 회의록 자동화 서비스 개발을 위한 전략 회의입니다.")
.status("FINALIZED")
.version(3)
.createdAt(LocalDateTime.of(2025, 10, 25, 14, 0))
.lastModifiedAt(LocalDateTime.of(2025, 10, 25, 17, 30))
.createdBy("김민준")
.lastModifiedBy("김민준")
.meeting(createMockMeetingInfo())
.dashboard(createMockDashboardInfo())
.agendas(createMockAgendaInfo())
.build();
}
/**
* Mock 회의 정보 생성 (프로토타입 기반)
*/
private MinutesDetailResponse.MeetingInfo createMockMeetingInfo() {
return MinutesDetailResponse.MeetingInfo.builder()
.meetingId("meeting-001")
.title("2025년 1분기 제품 기획 회의")
.scheduledAt(LocalDateTime.of(2025, 10, 25, 14, 0))
.startedAt(LocalDateTime.of(2025, 10, 25, 14, 0))
.endedAt(LocalDateTime.of(2025, 10, 25, 15, 30))
.organizerId("김민준")
.organizerName("김민준")
.location("본사 2층 대회의실")
.durationMinutes(90)
.participants(List.of(
MinutesDetailResponse.Participant.builder()
.userId("김민준")
.name("김민준")
.role("작성자")
.avatarColor("avatar-green")
.build(),
MinutesDetailResponse.Participant.builder()
.userId("박서연")
.name("박서연")
.role("참여자")
.avatarColor("avatar-blue")
.build(),
MinutesDetailResponse.Participant.builder()
.userId("이준호")
.name("이준호")
.role("참여자")
.avatarColor("avatar-yellow")
.build(),
MinutesDetailResponse.Participant.builder()
.userId("최유진")
.name("최유진")
.role("참여자")
.avatarColor("avatar-pink")
.build()
))
.build();
}
/**
* Mock 대시보드 정보 생성 (프로토타입 기반)
*/
private MinutesDetailResponse.DashboardInfo createMockDashboardInfo() {
return MinutesDetailResponse.DashboardInfo.builder()
.keyPoints(List.of(
MinutesDetailResponse.KeyPoint.builder()
.index(1)
.content("AI 기반 회의록 자동화 서비스 출시 결정. 타겟은 중소기업 및 스타트업.")
.build(),
MinutesDetailResponse.KeyPoint.builder()
.index(2)
.content("주요 기능: 음성인식, AI 요약, Todo 자동 추출, 실시간 검증 및 협업.")
.build(),
MinutesDetailResponse.KeyPoint.builder()
.index(3)
.content("개발 기간 3개월 (Phase 1-3), 베타 출시일 2025년 12월 1일.")
.build(),
MinutesDetailResponse.KeyPoint.builder()
.index(4)
.content("프리 런칭 캠페인 11월 진행, 초기 100팀 무료 제공 후 유료 전환.")
.build()
))
.keywords(List.of("#AI회의록", "#음성인식", "#협업도구", "#스타트업", "#베타출시"))
.stats(MinutesDetailResponse.Statistics.builder()
.participantCount(4)
.durationMinutes(90)
.agendaCount(3)
.todoCount(5)
.build())
.decisions(List.of(
MinutesDetailResponse.Decision.builder()
.content("베타 버전 출시일: 2025년 12월 1일")
.decidedBy("김민준")
.decidedAt(LocalDateTime.of(2025, 10, 25, 15, 30))
.background("개발 일정 및 시장 진입 시기를 고려하여 12월 초 출시가 최적. Q4 마무리 전 베타 피드백 확보 가능.")
.build(),
MinutesDetailResponse.Decision.builder()
.content("타겟 고객: 중소기업 및 스타트업")
.decidedBy("박서연")
.decidedAt(LocalDateTime.of(2025, 10, 25, 14, 45))
.background("사용자 인터뷰 결과, 중소기업과 스타트업이 회의록 작성에 가장 많은 시간을 소비하며 자동화 니즈가 높음.")
.build()
))
.todoProgress(createMockTodoProgress())
.relatedMinutes(createMockRelatedMinutes())
.build();
}
/**
* Mock Todo 진행상황 생성 (프로토타입 기반 - 간단한 텍스트)
*/
private MinutesDetailResponse.TodoProgress createMockTodoProgress() {
List<MinutesDetailResponse.SimpleTodo> todos = List.of(
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-001")
.title("API 명세서 작성")
.assigneeName("이준호")
.status("IN_PROGRESS")
.priority("HIGH")
.dueDate(LocalDateTime.of(2025, 10, 23, 18, 0))
.dueDayStatus("D-2")
.build(),
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-002")
.title("데이터베이스 스키마 설계")
.assigneeName("이준호")
.status("OVERDUE")
.priority("HIGH")
.dueDate(LocalDateTime.of(2025, 10, 20, 18, 0))
.dueDayStatus("D+1 (지연)")
.build(),
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-003")
.title("UI 프로토타입 디자인")
.assigneeName("최유진")
.status("IN_PROGRESS")
.priority("MEDIUM")
.dueDate(LocalDateTime.of(2025, 10, 28, 18, 0))
.dueDayStatus("D-7")
.build(),
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-004")
.title("사용자 피드백 분석")
.assigneeName("김민준")
.status("COMPLETED")
.priority("MEDIUM")
.dueDate(LocalDateTime.of(2025, 10, 19, 18, 0))
.dueDayStatus("완료")
.build(),
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-005")
.title("예산 편성안 검토")
.assigneeName("김민준")
.status("IN_PROGRESS")
.priority("HIGH")
.dueDate(LocalDateTime.of(2025, 10, 22, 18, 0))
.dueDayStatus("D-1")
.build()
);
int completedCount = (int) todos.stream().filter(t -> "COMPLETED".equals(t.getStatus())).count();
return MinutesDetailResponse.TodoProgress.builder()
.totalCount(todos.size())
.completedCount(completedCount)
.progressPercentage((completedCount * 100) / todos.size())
.todos(todos)
.build();
}
/**
* Mock 관련회의록 생성 (프로토타입 기반)
@@ -862,92 +558,6 @@ public class MinutesController {
);
}
/**
* Mock 안건 정보 생성 (프로토타입 기반 - 회의록 탭)
*/
private List<MinutesDetailResponse.AgendaInfo> createMockAgendaInfo() {
return List.of(
MinutesDetailResponse.AgendaInfo.builder()
.agendaId("agenda-001")
.title("1. 신제품 기획 방향")
.orderIndex(1)
.isVerified(true)
.verifiedBy("박서연")
.verifiedAt(LocalDateTime.of(2025, 10, 25, 16, 30))
.aiSummary(MinutesDetailResponse.AiSummary.builder()
.content("신제품은 AI 기반 회의록 자동화 서비스로 결정. 타겟은 중소기업 및 스타트업이며, 주요 기능은 음성인식, AI 요약, Todo 추출입니다. 경쟁사 대비 차별점은 실시간 검증 및 협업 기능입니다.")
.generatedAt(LocalDateTime.of(2025, 10, 25, 16, 30))
.modifiedAt(LocalDateTime.of(2025, 10, 25, 17, 0))
.build())
.details(MinutesDetailResponse.AgendaDetails.builder()
.discussions(List.of(
"AI 기반 회의록 자동화 서비스 출시 결정",
"타겟 고객: 중소기업, 스타트업",
"주요 기능: 음성인식, AI 요약, Todo 자동 추출",
"차별화 포인트: 실시간 검증, 협업 기능"
))
.decisions(List.of(
"베타 버전 출시일: 2025년 12월 1일",
"초기 목표 사용자: 100개 팀"
))
.build())
.relatedMinutes(createMockRelatedMinutes().subList(0, 3))
.build(),
MinutesDetailResponse.AgendaInfo.builder()
.agendaId("agenda-002")
.title("2. 개발 일정 및 리소스")
.orderIndex(2)
.isVerified(true)
.verifiedBy("이준호")
.verifiedAt(LocalDateTime.of(2025, 10, 25, 16, 32))
.aiSummary(MinutesDetailResponse.AiSummary.builder()
.content("개발 기간은 3개월로 설정. 백엔드 2명, 프론트 2명, AI 엔지니어 1명 투입. 주간 스프린트로 진행하며, 2주마다 베타 테스트 실시.")
.generatedAt(LocalDateTime.of(2025, 10, 25, 16, 32))
.build())
.details(MinutesDetailResponse.AgendaDetails.builder()
.discussions(List.of(
"Phase 1 (11월): 핵심 기능 개발 (음성인식, AI 요약)",
"Phase 2 (12월): 협업 기능 개발 (검증, 공유)",
"Phase 3 (1월): 베타 테스트 및 최적화"
))
.decisions(List.of(
"백엔드 개발자 2명",
"프론트엔드 개발자 2명",
"AI 엔지니어 1명"
))
.build())
.relatedMinutes(createMockRelatedMinutes().subList(1, 2))
.build(),
MinutesDetailResponse.AgendaInfo.builder()
.agendaId("agenda-003")
.title("3. 마케팅 전략")
.orderIndex(3)
.isVerified(true)
.verifiedBy("최유진")
.verifiedAt(LocalDateTime.of(2025, 10, 25, 16, 35))
.aiSummary(MinutesDetailResponse.AiSummary.builder()
.content("베타 출시 전 프리 런칭 캠페인 진행. 주요 채널은 LinkedIn 및 스타트업 커뮤니티. 초기 100팀 무료 제공 후 유료 전환 유도.")
.generatedAt(LocalDateTime.of(2025, 10, 25, 16, 35))
.build())
.details(MinutesDetailResponse.AgendaDetails.builder()
.discussions(List.of(
"기간: 11월 1일 ~ 11월 30일",
"채널: LinkedIn, Product Hunt, 스타트업 커뮤니티",
"목표: 500명 사전 신청"
))
.decisions(List.of(
"초기 100팀 무료 제공",
"피드백 수집 및 개선",
"1월부터 유료 전환"
))
.build())
.relatedMinutes(List.of())
.build()
);
}
private MinutesDetailResponse convertToMinutesDetailResponse(MinutesDTO minutesDTO) {
try {
// 실제 회의 정보 조회
@@ -978,8 +588,8 @@ public class MinutesController {
.build();
} catch (Exception e) {
log.warn("실제 데이터 조회 실패, 기본값 사용 - minutesId: {}", minutesDTO.getMinutesId(), e);
return buildFallbackResponse(minutesDTO);
log.error("실제 데이터 조회 실패 - minutesId: {}", minutesDTO.getMinutesId(), e);
throw new RuntimeException("회의록 상세 정보 조회에 실패했습니다", e);
}
}
@@ -1024,44 +634,88 @@ public class MinutesController {
*/
private List<MinutesDetailResponse.Participant> buildParticipantList(String meetingId) {
try {
// 실제 참석자 조회 (현재는 기본값 반환)
// TODO: MeetingService.getParticipants() 메소드 구현 필요
// 실제 참석자 조회
List<String> participantIds = participantReader.findParticipantsByMeetingId(meetingId);
// 임시로 기본 참석자 목록 반환
return List.of(
MinutesDetailResponse.Participant.builder()
.userId("user1")
.name("회의 생성자")
.role("생성자")
.avatarColor("avatar-green")
.build(),
MinutesDetailResponse.Participant.builder()
.userId("user2")
.name("참여자")
.role("참여자")
.avatarColor("avatar-blue")
.build()
);
if (participantIds.isEmpty()) {
log.info("참석자가 없습니다 - meetingId: {}", meetingId);
return new ArrayList<>();
}
// 참석자 정보를 MinutesDetailResponse.Participant로 변환
return participantIds.stream()
.map(userId -> MinutesDetailResponse.Participant.builder()
.userId(userId)
.name(getUserName(userId)) // TODO: User 서비스에서 실제 이름 조회
.role(determineRole(userId, meetingId)) // 역할 결정
.avatarColor(generateAvatarColor(userId)) // 아바타 색상 생성
.build())
.collect(Collectors.toList());
} catch (Exception e) {
log.warn("참석자 정보 조회 실패 - meetingId: {}", meetingId, e);
return List.of();
log.error("참석자 정보 조회 실패 - meetingId: {}", meetingId, e);
return new ArrayList<>();
}
}
/**
* 사용자 이름 조회 (임시 구현)
* TODO: User 서비스에서 실제 사용자 정보 조회
*/
private String getUserName(String userId) {
// 임시로 userId를 그대로 반환
// 실제로는 User 서비스에서 조회해야 함
return userId;
}
/**
* 참석자 역할 결정
*/
private String determineRole(String userId, String meetingId) {
try {
// 회의 정보를 조회해서 주최자인지 확인
var meeting = meetingService.getMeeting(meetingId);
if (meeting.getOrganizerId().equals(userId)) {
return "주최자";
}
} catch (Exception e) {
log.warn("회의 정보 조회 실패로 역할 확인 불가 - meetingId: {}", meetingId, e);
}
return "참석자";
}
/**
* 아바타 색상 생성
*/
private String generateAvatarColor(String userId) {
String[] colors = {"avatar-blue", "avatar-green", "avatar-purple", "avatar-orange", "avatar-red"};
int hash = userId.hashCode();
int index = Math.abs(hash) % colors.length;
return colors[index];
}
/**
* 안건 정보 목록 구성
*/
private List<MinutesDetailResponse.AgendaInfo> buildAgendaInfoList(String minutesId) {
try {
// 실제 안건 조회
var sections = minutesSectionService.getSectionsByMinutes(minutesId);
// agenda_sections 테이블에서 실제 안건 조회
var agendaSections = agendaSectionService.getAgendaSectionsByMinutesId(minutesId);
return sections.stream()
.map(this::convertToAgendaInfo)
.collect(Collectors.toList());
if (!agendaSections.isEmpty()) {
// agenda_sections 데이터가 있으면 이를 사용
return agendaSections.stream()
.map(this::convertAgendaSectionToAgendaInfo)
.collect(Collectors.toList());
} else {
// agenda_sections 데이터가 없으면 기존 minutes_sections 데이터 사용
var sections = minutesSectionService.getSectionsByMinutes(minutesId);
return sections.stream()
.map(this::convertToAgendaInfo)
.collect(Collectors.toList());
}
} catch (Exception e) {
log.warn("안건 정보 조회 실패 - minutesId: {}", minutesId, e);
return createSampleAgendas();
log.error("안건 정보 조회 실패 - minutesId: {}", minutesId, e);
return new ArrayList<>();
}
}
@@ -1070,8 +724,8 @@ public class MinutesController {
*/
private MinutesDetailResponse.TodoProgress buildTodoProgress(String minutesId) {
try {
// 실제 Todo 목록 조회
var todos = todoService.getTodosByMinutes(minutesId);
// 실제 Todo 목록 조회 (상태 관계없이 모든 todos)
List<com.unicorn.hgzero.meeting.biz.domain.Todo> todos = todoReader.findByMinutesId(minutesId);
int totalCount = todos.size();
int completedCount = (int) todos.stream()
@@ -1079,9 +733,18 @@ public class MinutesController {
.count();
List<MinutesDetailResponse.SimpleTodo> simpleTodos = todos.stream()
.map(this::convertToSimpleTodo)
.map(todo -> MinutesDetailResponse.SimpleTodo.builder()
.todoId(todo.getTodoId())
.title(todo.getTitle())
.assigneeName(todo.getAssigneeId()) // 담당자 ID를 그대로 사용
.dueDate(todo.getDueDate() != null ?
todo.getDueDate().atStartOfDay() :
null) // LocalDate를 LocalDateTime으로 변환
.build())
.collect(Collectors.toList());
log.info("Todo 조회 성공 - minutesId: {}, totalCount: {}", minutesId, totalCount);
return MinutesDetailResponse.TodoProgress.builder()
.totalCount(totalCount)
.completedCount(completedCount)
@@ -1089,8 +752,13 @@ public class MinutesController {
.todos(simpleTodos)
.build();
} catch (Exception e) {
log.warn("Todo 정보 조회 실패 - minutesId: {}", minutesId, e);
return createSampleTodoProgress();
log.error("Todo 정보 조회 실패 - minutesId: {}", minutesId, e);
return MinutesDetailResponse.TodoProgress.builder()
.totalCount(0)
.completedCount(0)
.progressPercentage(0)
.todos(new ArrayList<>())
.build();
}
}
@@ -1135,30 +803,6 @@ public class MinutesController {
.build();
}
/**
* 폴백 응답 구성
*/
private MinutesDetailResponse buildFallbackResponse(MinutesDTO minutesDTO) {
MinutesDetailResponse.MeetingInfo meetingInfo = buildDefaultMeetingInfo(minutesDTO);
MinutesDetailResponse.TodoProgress todoProgress = createSampleTodoProgress();
List<MinutesDetailResponse.AgendaInfo> agendas = createSampleAgendas();
MinutesDetailResponse.DashboardInfo dashboardInfo = buildDashboardInfo(minutesDTO, agendas, todoProgress);
return MinutesDetailResponse.builder()
.minutesId(minutesDTO.getMinutesId())
.title(minutesDTO.getTitle())
.memo(minutesDTO.getMemo() != null ? minutesDTO.getMemo() : "")
.status(minutesDTO.getStatus())
.version(minutesDTO.getVersion())
.createdAt(minutesDTO.getCreatedAt())
.lastModifiedAt(minutesDTO.getLastModifiedAt())
.createdBy(minutesDTO.getCreatedBy())
.lastModifiedBy(minutesDTO.getLastModifiedBy())
.meeting(meetingInfo)
.dashboard(dashboardInfo)
.agendas(agendas)
.build();
}
// === 헬퍼 메소드들 ===
@@ -1181,7 +825,7 @@ public class MinutesController {
private MinutesDetailResponse.AgendaInfo convertToAgendaInfo(Object section) {
if (!(section instanceof MinutesSection)) {
log.warn("MinutesSection이 아닌 객체가 전달됨: {}", section.getClass().getSimpleName());
return createSampleAgenda("변환 실패 안건", 1);
return null;
}
MinutesSection minutesSection = (MinutesSection) section;
@@ -1212,6 +856,73 @@ public class MinutesController {
.build();
}
/**
* AgendaSection을 AgendaInfo로 변환
*/
private MinutesDetailResponse.AgendaInfo convertAgendaSectionToAgendaInfo(AgendaSection agendaSection) {
// AI 요약 정보 구성
MinutesDetailResponse.AiSummary aiSummary = MinutesDetailResponse.AiSummary.builder()
.content(agendaSection.getAiSummaryShort() != null ? agendaSection.getAiSummaryShort() : "")
.generatedAt(agendaSection.getCreatedAt() != null ? agendaSection.getCreatedAt() : LocalDateTime.now())
.modifiedAt(agendaSection.getUpdatedAt() != null ? agendaSection.getUpdatedAt() : LocalDateTime.now())
.build();
// 안건 상세 내용 구성 - JSON 파싱
List<String> discussionsList = parseJsonToList(agendaSection.getDiscussions());
List<String> decisionsList = parseJsonToList(agendaSection.getDecisions());
MinutesDetailResponse.AgendaDetails details = MinutesDetailResponse.AgendaDetails.builder()
.discussions(discussionsList)
.decisions(decisionsList)
.build();
return MinutesDetailResponse.AgendaInfo.builder()
.agendaId(agendaSection.getId())
.title(agendaSection.getAgendaTitle() != null ? agendaSection.getAgendaTitle() : "제목 없음")
.orderIndex(agendaSection.getAgendaNumber() != null ? agendaSection.getAgendaNumber() : 1)
.isVerified(true) // agenda_sections는 기본적으로 검증된 데이터
.verifiedBy("AI")
.verifiedAt(agendaSection.getCreatedAt())
.aiSummary(aiSummary)
.details(details)
.relatedMinutes(new ArrayList<>()) // 관련 회의록은 별도 로직 필요
.build();
}
/**
* JSON 문자열을 List<String>으로 파싱
*/
private List<String> parseJsonToList(String json) {
if (json == null || json.isEmpty()) {
return new ArrayList<>();
}
try {
// 간단한 JSON 배열 파싱
json = json.trim();
if (json.startsWith("[") && json.endsWith("]")) {
json = json.substring(1, json.length() - 1);
if (json.isEmpty()) {
return new ArrayList<>();
}
// 쉼표로 분리하고 따옴표 제거
return Arrays.stream(json.split(","))
.map(s -> s.trim())
.map(s -> s.startsWith("\"") && s.endsWith("\"") ? s.substring(1, s.length() - 1) : s)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
// JSON 형식이 아니면 그대로 한 줄로 반환
return List.of(json);
} catch (Exception e) {
log.warn("JSON 파싱 실패: {}", json, e);
return List.of(json); // 파싱 실패시 원본 텍스트를 그대로 반환
}
}
private MinutesDetailResponse.SimpleTodo convertToSimpleTodo(Object todo) {
if (!(todo instanceof Todo)) {
log.warn("Todo가 아닌 객체가 전달됨: {}", todo.getClass().getSimpleName());
@@ -1378,10 +1089,7 @@ public class MinutesController {
}
}
// 샘플 데이터로 보완
if (decisions.isEmpty()) {
decisions = createSampleDecisions();
}
// 실제 데이터만 사용
return decisions;
}
@@ -1409,145 +1117,9 @@ public class MinutesController {
);
}
private List<MinutesDetailResponse.Decision> createSampleDecisions() {
return List.of(
MinutesDetailResponse.Decision.builder()
.content("베타 버전 출시일: 2025년 12월 1일")
.decidedBy("김민준")
.decidedAt(LocalDateTime.now().minusHours(2))
.background("개발 일정 및 시장 진입 시기를 고려하여 12월 초 출시가 최적. Q4 마무리 전 베타 피드백 확보 가능.")
.build(),
MinutesDetailResponse.Decision.builder()
.content("타겟 고객: 중소기업 및 스타트업")
.decidedBy("박서연")
.decidedAt(LocalDateTime.now().minusHours(3))
.background("사용자 인터뷰 결과, 중소기업과 스타트업이 회의록 작성에 가장 많은 시간을 소비하며 자동화 니즈가 높음.")
.build()
);
}
private MinutesDetailResponse.TodoProgress createSampleTodoProgress() {
List<MinutesDetailResponse.SimpleTodo> todos = List.of(
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-1")
.title("데이터베이스 스키마 설계")
.assigneeName("이준호")
.status("IN_PROGRESS")
.priority("HIGH")
.dueDate(LocalDateTime.now().minusDays(8))
.dueDayStatus("D+8")
.build(),
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-2")
.title("API 명세서 작성")
.assigneeName("이준호")
.status("IN_PROGRESS")
.priority("MEDIUM")
.dueDate(LocalDateTime.now().minusDays(5))
.dueDayStatus("D+5")
.build(),
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-3")
.title("예산 편성안 검토")
.assigneeName("김민준")
.status("COMPLETED")
.priority("HIGH")
.dueDate(LocalDateTime.now().minusDays(6))
.dueDayStatus("완료")
.build(),
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-4")
.title("UI 프로토타입 디자인")
.assigneeName("최유진")
.status("IN_PROGRESS")
.priority("MEDIUM")
.dueDate(LocalDateTime.now())
.dueDayStatus("D-Day")
.build(),
MinutesDetailResponse.SimpleTodo.builder()
.todoId("todo-5")
.title("사용자 피드백 분석")
.assigneeName("김민준")
.status("OVERDUE")
.priority("LOW")
.dueDate(LocalDateTime.now().minusDays(9))
.dueDayStatus("D+9")
.build()
);
int totalCount = todos.size();
int completedCount = (int) todos.stream()
.filter(todo -> "COMPLETED".equals(todo.getStatus()))
.count();
return MinutesDetailResponse.TodoProgress.builder()
.totalCount(totalCount)
.completedCount(completedCount)
.progressPercentage(calculateProgressPercentage(totalCount, completedCount))
.todos(todos)
.build();
}
private List<MinutesDetailResponse.RelatedMinutes> createSampleRelatedMinutes() {
return List.of(
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-002")
.title("AI 기능 개선 회의")
.meetingDate(LocalDateTime.now().minusDays(2))
.author("이준호")
.relevancePercentage(92)
.relevanceLevel("HIGH")
.summary("AI 요약 정확도 개선 방안 논의. BERT 모델 도입 및 학습 데이터 확보 계획 수립.")
.build(),
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-003")
.title("개발 리소스 계획 회의")
.meetingDate(LocalDateTime.now().minusDays(3))
.author("김민준")
.relevancePercentage(88)
.relevanceLevel("MEDIUM")
.summary("Q4 개발 리소스 현황 및 배분 계획. 신규 프로젝트 우선순위 협의.")
.build(),
MinutesDetailResponse.RelatedMinutes.builder()
.minutesId("minutes-004")
.title("경쟁사 분석 회의")
.meetingDate(LocalDateTime.now().minusDays(5))
.author("박서연")
.relevancePercentage(78)
.relevanceLevel("MEDIUM")
.summary("경쟁사 A, B, C 분석 결과. 우리의 차별점은 실시간 협업 및 검증 기능.")
.build()
);
}
private List<MinutesDetailResponse.AgendaInfo> createSampleAgendas() {
return List.of(
createSampleAgenda("신제품 기획 방향", 1),
createSampleAgenda("개발 일정 및 리소스", 2),
createSampleAgenda("마케팅 전략", 3)
);
}
private MinutesDetailResponse.AgendaInfo createSampleAgenda(String title, int order) {
return MinutesDetailResponse.AgendaInfo.builder()
.agendaId("agenda-" + order)
.title(order + ". " + title)
.orderIndex(order)
.isVerified(true)
.verifiedBy("검증자")
.verifiedAt(LocalDateTime.now().minusHours(1))
.aiSummary(MinutesDetailResponse.AiSummary.builder()
.content(title + "에 대한 AI 요약 내용입니다.")
.generatedAt(LocalDateTime.now().minusHours(2))
.modifiedAt(LocalDateTime.now().minusHours(1))
.build())
.details(MinutesDetailResponse.AgendaDetails.builder()
.discussions(List.of("논의 사항 1", "논의 사항 2"))
.decisions(List.of("결정 사항 1", "결정 사항 2"))
.build())
.relatedMinutes(createSampleRelatedMinutes().subList(0, 1))
.build();
}
/**
* AI 분석 결과로 응답 데이터 향상
@@ -0,0 +1,93 @@
package com.unicorn.hgzero.meeting.infra.gateway.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import jakarta.persistence.*;
import lombok.*;
/**
* 회의 안건 섹션 엔티티
* agenda_sections 테이블과 매핑
*/
@Entity
@Table(name = "agenda_sections")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgendaSectionEntity extends BaseTimeEntity {
@Id
@Column(length = 36)
private String id;
@Column(name = "minutes_id", length = 36, nullable = false)
private String minutesId;
@Column(name = "meeting_id", length = 50, nullable = false)
private String meetingId;
@Column(name = "agenda_number", nullable = false)
private Integer agendaNumber;
@Column(name = "agenda_title", length = 200, nullable = false)
private String agendaTitle;
@Column(name = "ai_summary_short", columnDefinition = "TEXT")
private String aiSummaryShort;
@Column(name = "discussions", columnDefinition = "TEXT")
private String discussions;
@Column(name = "decisions", columnDefinition = "TEXT")
private String decisions;
@Column(name = "opinions", columnDefinition = "TEXT")
private String opinions;
@Column(name = "pending_items", columnDefinition = "TEXT")
private String pendingItems;
@Column(name = "todos", columnDefinition = "TEXT")
private String todos;
/**
* 도메인 객체로 변환
*/
public AgendaSection toDomain() {
return AgendaSection.builder()
.id(this.id)
.minutesId(this.minutesId)
.meetingId(this.meetingId)
.agendaNumber(this.agendaNumber)
.agendaTitle(this.agendaTitle)
.aiSummaryShort(this.aiSummaryShort)
.discussions(this.discussions)
.decisions(this.decisions)
.opinions(this.opinions)
.pendingItems(this.pendingItems)
.todos(this.todos)
.createdAt(this.getCreatedAt())
.updatedAt(this.getUpdatedAt())
.build();
}
/**
* 도메인 객체에서 엔티티 생성
*/
public static AgendaSectionEntity fromDomain(AgendaSection domain) {
return AgendaSectionEntity.builder()
.id(domain.getId())
.minutesId(domain.getMinutesId())
.meetingId(domain.getMeetingId())
.agendaNumber(domain.getAgendaNumber())
.agendaTitle(domain.getAgendaTitle())
.aiSummaryShort(domain.getAiSummaryShort())
.discussions(domain.getDiscussions())
.decisions(domain.getDecisions())
.opinions(domain.getOpinions())
.pendingItems(domain.getPendingItems())
.todos(domain.getTodos())
.build();
}
}
@@ -0,0 +1,28 @@
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;
/**
* 회의 안건 섹션 리포지토리
*/
@Repository
public interface AgendaSectionRepository extends JpaRepository<AgendaSectionEntity, String> {
/**
* 회의록 ID로 안건 섹션 목록 조회
* @param minutesId 회의록 ID
* @return 안건 섹션 목록
*/
List<AgendaSectionEntity> findByMinutesIdOrderByAgendaNumber(String minutesId);
/**
* 회의 ID로 안건 섹션 목록 조회
* @param meetingId 회의 ID
* @return 안건 섹션 목록
*/
List<AgendaSectionEntity> findByMeetingIdOrderByAgendaNumber(String meetingId);
}
+15 -1
View File
@@ -27,8 +27,22 @@ spring:
format_sql: true
use_sql_comments: true
dialect: org.hibernate.dialect.PostgreSQLDialect
hbm2ddl:
auto: none
temp:
use_jdbc_metadata_defaults: false
jdbc:
lob:
non_contextual_creation: true
javax:
persistence:
schema-generation:
database:
action: none
scripts:
action: none
hibernate:
ddl-auto: ${JPA_DDL_AUTO:none}
ddl-auto: none
# Redis Configuration
data: