Merge branch 'main' of https://github.com/hwanny1128/HGZero into chore/path

This commit is contained in:
cyjadela
2025-10-27 17:02:02 +09:00
230 changed files with 16994 additions and 20484 deletions
@@ -18,12 +18,14 @@ import com.unicorn.hgzero.meeting.biz.usecase.out.SessionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionWriter;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
import com.unicorn.hgzero.meeting.infra.event.publisher.EventPublisher;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
@@ -326,9 +328,12 @@ public class MeetingService implements
log.debug("Found minutes: {}", minutes.getTitle());
}
// 5. AI 분석 수행 (현재는 Mock 데이터로 구현)
MeetingAnalysis analysis = performAIAnalysis(meeting, minutes);
// 5. 기본 분석 정보 생성 (실제 AI 분석은 AI 서비스에서 비동기 처리)
MeetingAnalysis analysis = createBasicAnalysis(meeting, minutes);
meetingAnalysisWriter.save(analysis);
// TODO: AI 서비스에 비동기 분석 요청 전송
// aiAnalysisClient.requestAnalysis(meeting.getMeetingId(), minutes.getMinutesId());
// 6. 결과 DTO 구성
MeetingEndDTO result = buildMeetingEndDTO(meeting, analysis);
@@ -338,55 +343,20 @@ public class MeetingService implements
}
/**
* AI 분석 수행 (Mock 구현)
* 간단한 회의 분석 수행 (AI 서비스 없이 기본 정보만 처리)
* AI 분석은 별도 AI 서비스에서 비동기로 처리되어야 함
*/
private MeetingAnalysis performAIAnalysis(Meeting meeting, Minutes minutes) {
log.info("Performing AI analysis for meeting: {}", meeting.getMeetingId());
// Mock 데이터로 구현 (실제로는 AI 서비스 호출)
List<String> keywords = List.of(
"#신제품기획", "#예산편성", "#일정조율",
"#시장조사", "#UI/UX", "#개발스펙"
);
List<MeetingAnalysis.AgendaAnalysis> agendaAnalyses = List.of(
MeetingAnalysis.AgendaAnalysis.builder()
.agendaId("agenda-1")
.title("1. 신제품 기획 방향성")
.aiSummaryShort("타겟 고객을 20-30대로 설정, UI/UX 개선 집중")
.discussion("신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고, 기존 제품 대비 UI/UX를 대폭 개선하기로 함")
.decisions(List.of("타겟 고객: 20-30대 직장인", "UI/UX 개선을 최우선 과제로 설정"))
.pending(List.of())
.extractedTodos(List.of("시장 조사 보고서 작성", "UI/UX 개선안 초안 작성"))
.build(),
MeetingAnalysis.AgendaAnalysis.builder()
.agendaId("agenda-2")
.title("2. 예산 편성 및 일정")
.aiSummaryShort("총 예산 5억, 개발 기간 6개월 확정")
.discussion("신제품 개발을 위한 총 예산을 5억원으로 책정하고, 개발 기간은 6개월로 확정함")
.decisions(List.of("총 예산: 5억원", "개발 기간: 6개월", "예산 배분: 개발 60%, 마케팅 40%"))
.pending(List.of("세부 일정 확정은 다음 회의에서 논의"))
.extractedTodos(List.of("세부 개발 일정 수립"))
.build(),
MeetingAnalysis.AgendaAnalysis.builder()
.agendaId("agenda-3")
.title("3. 기술 스택 및 개발 방향")
.aiSummaryShort("React 기반 프론트엔드, AI 챗봇 기능 추가")
.discussion("프론트엔드는 React 기반으로 개발하고, 고객 지원을 위한 AI 챗봇 기능을 추가하기로 함")
.decisions(List.of("프론트엔드: React 기반", "AI 챗봇 기능 추가", "Next.js 도입 검토"))
.pending(List.of("AI 챗봇 학습 데이터 확보 방안"))
.extractedTodos(List.of("AI 챗봇 프로토타입 개발", "Next.js 도입 검토 보고서"))
.build()
);
private MeetingAnalysis createBasicAnalysis(Meeting meeting, Minutes minutes) {
log.info("Creating basic analysis for meeting: {}", meeting.getMeetingId());
// 기본 분석 정보만 생성 (키워드나 상세 분석은 AI 서비스에서 별도 처리)
return MeetingAnalysis.builder()
.analysisId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.minutesId(minutes.getMinutesId())
.keywords(keywords)
.agendaAnalyses(agendaAnalyses)
.status("COMPLETED")
.completedAt(LocalDateTime.now())
.keywords(List.of()) // AI 서비스에서 별도로 채워짐
.agendaAnalyses(List.of()) // AI 서비스에서 별도로 채워짐
.status("PENDING") // AI 처리 대기 상태
.createdAt(LocalDateTime.now())
.build();
}
@@ -395,10 +365,11 @@ public class MeetingService implements
* MeetingEndDTO 구성
*/
private MeetingEndDTO buildMeetingEndDTO(Meeting meeting, MeetingAnalysis analysis) {
// 회의 시간 계산 (Mock)
int durationMinutes = 90;
// 회의 시간 및 참석자 수 계산 (실제 데이터 기반)
int durationMinutes = calculateActualDuration(meeting);
int participantCount = calculateActualParticipantCount(meeting.getMeetingId());
// 전체 Todo 개수 계산
// 전체 Todo 개수 계산 (AI 분석 완료되기 전까지는 0)
int totalTodos = analysis.getAgendaAnalyses().stream()
.mapToInt(agenda -> agenda.getExtractedTodos().size())
.sum();
@@ -423,7 +394,7 @@ public class MeetingService implements
return MeetingEndDTO.builder()
.title(meeting.getTitle())
.participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0)
.participantCount(participantCount)
.durationMinutes(durationMinutes)
.agendaCount(analysis.getAgendaAnalyses().size())
.todoCount(totalTodos)
@@ -459,6 +430,36 @@ public class MeetingService implements
return updatedMeeting;
}
/**
* 회의 실제 진행 시간 계산
*/
private int calculateActualDuration(Meeting meeting) {
if (meeting.getStartedAt() != null && meeting.getEndedAt() != null) {
// 실제 시작/종료 시간이 있으면 실제 진행 시간 계산
Duration duration = Duration.between(meeting.getStartedAt(), meeting.getEndedAt());
return (int) duration.toMinutes();
} else if (meeting.getScheduledAt() != null && meeting.getEndTime() != null) {
// 예정 시간 기준으로 계산
Duration duration = Duration.between(meeting.getScheduledAt(), meeting.getEndTime());
return (int) duration.toMinutes();
} else {
// 기본값 (1시간)
return 60;
}
}
/**
* 실제 참석자 수 계산
*/
private int calculateActualParticipantCount(String meetingId) {
// 실제 참석한 참석자 수 계산 (meeting_participants 테이블에서 attended=true인 수)
// 현재는 기본적으로 주최자 + 참석자 수로 계산
List<String> participants = meetingReader.findById(meetingId)
.map(Meeting::getParticipants)
.orElse(List.of());
return participants.size() + 1; // 주최자 포함
}
/**
* ID로 회의 조회
*/
@@ -541,12 +542,31 @@ public class MeetingService implements
// 참석자 저장
participantWriter.saveParticipant(command.meetingId(), command.email());
// TODO: 실제 이메일 발송 구현 필요
// 이메일 발송 서비스 호출
// emailService.sendInvitation(command.email(), meeting, command.frontendUrl());
// 현재는 로그만 남기고 성공으로 처리
log.info("Invitation email would be sent to {} for meeting {} (Frontend URL: {})",
command.email(), meeting.getTitle(), command.frontendUrl());
// 회의 초대 알림 이벤트 발행
try {
NotificationRequestEvent event = NotificationRequestEvent.builder()
.notificationType("MEETING_INVITATION")
.recipientEmail(command.email())
.recipientId(command.email())
.recipientName(command.email())
.title("회의 초대")
.message(String.format("'%s' 회의에 초대되었습니다. 일시: %s, 장소: %s, 참여 링크: %s",
meeting.getTitle(), meeting.getScheduledAt(), meeting.getLocation(), command.frontendUrl()))
.relatedEntityId(command.meetingId())
.relatedEntityType("MEETING")
.requestedBy(meeting.getOrganizerId())
.requestedByName(command.inviterName())
.eventTime(LocalDateTime.now())
.build();
eventPublisher.publishNotificationRequest(event);
log.info("Meeting invitation event published for email: {}, meetingId: {}",
command.email(), command.meetingId());
} catch (Exception e) {
log.error("Failed to publish meeting invitation event: meetingId={}, email={}",
command.meetingId(), command.email(), e);
// 이벤트 발행 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
log.info("Participant invited successfully: {} to meeting {}", command.email(), command.meetingId());
}
@@ -1,7 +1,10 @@
package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.meeting.biz.domain.Dashboard;
import com.unicorn.hgzero.meeting.biz.usecase.in.dashboard.GetDashboardUseCase;
import com.unicorn.hgzero.meeting.infra.dto.response.DashboardResponse;
import com.unicorn.hgzero.meeting.infra.mapper.DashboardResponseMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -9,7 +12,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
@@ -28,8 +30,11 @@ import java.util.List;
@Slf4j
public class DashboardController {
private final GetDashboardUseCase getDashboardUseCase;
private final DashboardResponseMapper dashboardResponseMapper;
/**
* 대시보드 데이터 조회 (목 데이터)
* 대시보드 데이터 조회
*
* @param userId 사용자 ID
* @return 대시보드 데이터
@@ -50,80 +55,61 @@ public class DashboardController {
log.info("대시보드 데이터 조회 요청 - userId: {}", userId);
// 목 데이터 생성
DashboardResponse mockResponse = createMockDashboardData();
try {
// 실제 데이터 조회
Dashboard dashboard = getDashboardUseCase.getDashboard(userId);
// 도메인 객체를 응답 DTO로 변환
DashboardResponse response = dashboardResponseMapper.toResponse(dashboard);
log.info("대시보드 데이터 조회 완료 - userId: {}", userId);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("대시보드 데이터 조회 실패 - userId: {}", userId, e);
throw e;
}
}
/**
* 기간별 대시보드 데이터 조회
*
* @param userId 사용자 ID
* @param period 조회 기간 (1day, 3days, 7days, 30days, 90days)
* @return 기간별 대시보드 데이터
*/
@Operation(
summary = "기간별 대시보드 데이터 조회",
description = "사용자별 맞춤 대시보드 정보를 기간 필터로 조회합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@GetMapping("/period/{period}")
public ResponseEntity<ApiResponse<DashboardResponse>> getDashboardByPeriod(
@Parameter(description = "사용자 ID", required = true)
@RequestHeader("X-User-Id") String userId,
@Parameter(description = "사용자명", required = true)
@RequestHeader("X-User-Name") String userName,
@Parameter(description = "사용자 이메일", required = true)
@RequestHeader("X-User-Email") String userEmail,
@Parameter(description = "조회 기간", required = true)
@PathVariable String period) {
log.info("대시보드 데이터 조회 완료 - userId: {}", userId);
log.info("기간별 대시보드 데이터 조회 요청 - userId: {}, period: {}", userId, period);
return ResponseEntity.ok(ApiResponse.success(mockResponse));
try {
// 실제 데이터 조회
Dashboard dashboard = getDashboardUseCase.getDashboardByPeriod(userId, period);
// 도메인 객체를 응답 DTO로 변환
DashboardResponse response = dashboardResponseMapper.toResponse(dashboard);
log.info("기간별 대시보드 데이터 조회 완료 - userId: {}, period: {}", userId, period);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("기간별 대시보드 데이터 조회 실패 - userId: {}, period: {}", userId, period, e);
throw e;
}
}
/**
* 목 데이터 생성
*/
private DashboardResponse createMockDashboardData() {
// 예정된 회의 목 데이터
List<DashboardResponse.UpcomingMeetingResponse> upcomingMeetings = Arrays.asList(
DashboardResponse.UpcomingMeetingResponse.builder()
.meetingId("550e8400-e29b-41d4-a716-446655440001")
.title("Q1 전략 회의")
.startTime(LocalDateTime.now().plusDays(2).withHour(14).withMinute(0))
.endTime(LocalDateTime.now().plusDays(2).withHour(16).withMinute(0))
.location("회의실 A")
.participantCount(5)
.status("SCHEDULED")
.build(),
DashboardResponse.UpcomingMeetingResponse.builder()
.meetingId("550e8400-e29b-41d4-a716-446655440002")
.title("개발팀 스프린트 계획")
.startTime(LocalDateTime.now().plusDays(3).withHour(10).withMinute(0))
.endTime(LocalDateTime.now().plusDays(3).withHour(12).withMinute(0))
.location("회의실 B")
.participantCount(8)
.status("SCHEDULED")
.build()
);
// 최근 회의록 목 데이터
List<DashboardResponse.RecentMinutesResponse> recentMinutes = Arrays.asList(
DashboardResponse.RecentMinutesResponse.builder()
.minutesId("770e8400-e29b-41d4-a716-446655440001")
.title("아키텍처 설계 회의")
.meetingDate(LocalDateTime.now().minusDays(1).withHour(14).withMinute(0))
.status("FINALIZED")
.participantCount(6)
.lastModified(LocalDateTime.now().minusDays(1).withHour(16).withMinute(30))
.build(),
DashboardResponse.RecentMinutesResponse.builder()
.minutesId("770e8400-e29b-41d4-a716-446655440002")
.title("UI/UX 검토 회의")
.meetingDate(LocalDateTime.now().minusDays(3).withHour(11).withMinute(0))
.status("FINALIZED")
.participantCount(4)
.lastModified(LocalDateTime.now().minusDays(3).withHour(12).withMinute(45))
.build(),
DashboardResponse.RecentMinutesResponse.builder()
.minutesId("770e8400-e29b-41d4-a716-446655440003")
.title("API 설계 검토")
.meetingDate(LocalDateTime.now().minusDays(5).withHour(15).withMinute(0))
.status("DRAFT")
.participantCount(3)
.lastModified(LocalDateTime.now().minusDays(5).withHour(16).withMinute(15))
.build()
);
// 통계 정보 목 데이터
DashboardResponse.StatisticsResponse statistics = DashboardResponse.StatisticsResponse.builder()
.upcomingMeetingsCount(2)
.activeTodosCount(0) // activeTodos 제거로 0으로 설정
.todoCompletionRate(0.0) // activeTodos 제거로 0으로 설정
.build();
return DashboardResponse.builder()
.upcomingMeetings(upcomingMeetings)
.activeTodos(Collections.emptyList()) // activeTodos 빈 리스트로 설정
.myMinutes(recentMinutes)
.statistics(statistics)
.build();
}
}
@@ -1,7 +1,10 @@
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.common.exception.ErrorCode;
import com.unicorn.hgzero.meeting.biz.dto.MeetingDTO;
import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO;
import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.*;
import com.unicorn.hgzero.meeting.infra.dto.request.CreateMeetingRequest;
import com.unicorn.hgzero.meeting.infra.dto.request.InviteParticipantRequest;
@@ -191,101 +194,25 @@ public class MeetingController {
log.info("회의 종료 요청 - meetingId: {}, userId: {}", meetingId, userId);
// Mock 데이터로 응답 (개발용)
var response = createMockMeetingEndResponse(meetingId);
log.info("회의 종료 완료 (Mock) - meetingId: {}", meetingId);
return ResponseEntity.ok(ApiResponse.success(response));
try {
// 실제 비즈니스 로직 호출
MeetingEndDTO meetingEndDTO = endMeetingUseCase.endMeeting(meetingId);
// DTO를 응답 객체로 변환
MeetingEndResponse response = MeetingEndResponse.from(meetingEndDTO);
log.info("회의 종료 완료 - meetingId: {}", meetingId);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (BusinessException e) {
log.error("회의 종료 실패 - meetingId: {}, error: {}", meetingId, e.getMessage());
throw e;
} catch (Exception e) {
log.error("회의 종료 중 예상치 못한 오류 - meetingId: {}", meetingId, e);
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, "회의 종료 처리 중 오류가 발생했습니다.");
}
}
/**
* 회의 종료 응답 Mock 데이터 생성
*
* @param meetingId 회의 ID
* @return Mock 회의 종료 응답
*/
private MeetingEndResponse createMockMeetingEndResponse(String meetingId) {
return MeetingEndResponse.builder()
.title("Q1 전략 기획 회의")
.participantCount(4)
.durationMinutes(90)
.agendaCount(3)
.todoCount(5)
.keywords(List.of("신제품 기획", "마케팅 전략", "예산 계획", "UI/UX 개선", "고객 분석"))
.agendaSummaries(List.of(
MeetingEndResponse.AgendaSummary.builder()
.title("1. 신제품 기획 방향성")
.aiSummaryShort("타겟 고객을 20-30대로 설정하고 UI/UX 개선에 집중하기로 결정")
.details(MeetingEndResponse.AgendaDetails.builder()
.discussion("신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고 모바일 중심의 사용자 경험을 강화하는 방향으로 논의됨")
.decisions(List.of(
"타겟 고객: 20-30대 직장인",
"플랫폼: 모바일 우선",
"핵심 기능: 간편 결제, 개인화 추천"
))
.pending(List.of(
"경쟁사 분석 보완 필요",
"기술 스택 최종 검토"
))
.build())
.todos(List.of(
MeetingEndResponse.TodoSummary.builder()
.title("시장 조사 보고서 작성")
.build(),
MeetingEndResponse.TodoSummary.builder()
.title("와이어프레임 초안 제작")
.build()
))
.build(),
MeetingEndResponse.AgendaSummary.builder()
.title("2. 마케팅 전략 수립")
.aiSummaryShort("SNS 마케팅과 인플루언서 협업을 통한 브랜드 인지도 향상 계획")
.details(MeetingEndResponse.AgendaDetails.builder()
.discussion("초기 론칭 시 SNS 중심의 마케팅 전략과 마이크로 인플루언서 협업을 통한 브랜드 인지도 향상 방안 논의")
.decisions(List.of(
"마케팅 채널: 인스타그램, 틱톡 우선",
"예산 배분: 인플루언서 50%, 광고 30%, 이벤트 20%",
"론칭 시기: 2024년 2분기"
))
.pending(List.of(
"인플루언서 리스트 검토",
"마케팅 예산 최종 승인"
))
.build())
.todos(List.of(
MeetingEndResponse.TodoSummary.builder()
.title("인플루언서 후보 리스트 작성")
.build(),
MeetingEndResponse.TodoSummary.builder()
.title("마케팅 예산안 상세 작성")
.build()
))
.build(),
MeetingEndResponse.AgendaSummary.builder()
.title("3. 프로젝트 일정 및 리소스")
.aiSummaryShort("개발 6개월, 테스트 2개월로 총 8개월 일정 확정")
.details(MeetingEndResponse.AgendaDetails.builder()
.discussion("전체 프로젝트 일정을 8개월로 설정하고 개발팀 6명, 디자인팀 2명으로 팀 구성 확정")
.decisions(List.of(
"전체 일정: 8개월 (개발 6개월, 테스트 2개월)",
"팀 구성: 개발 6명, 디자인 2명, PM 1명",
"주요 마일스톤: MVP 3개월, 베타 6개월, 정식 출시 8개월"
))
.pending(List.of(
"개발자 추가 채용 검토",
"외부 업체 협업 범위 논의"
))
.build())
.todos(List.of(
MeetingEndResponse.TodoSummary.builder()
.title("개발자 채용 공고 작성")
.build()
))
.build()
))
.build();
}
/**
* 회의 정보 조회
@@ -58,44 +58,51 @@ public class MinutesController {
@RequestHeader("X-User-Id") String userId,
@RequestHeader("X-User-Name") String userName,
@Parameter(description = "페이지 번호 (0부터 시작)") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size,
@Parameter(description = "정렬 기준 (createdAt, lastModifiedAt)") @RequestParam(defaultValue = "lastModifiedAt") String sortBy,
@Parameter(description = "정렬 방향 (asc, desc)") @RequestParam(defaultValue = "desc") String sortDir) {
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "10") int size,
@Parameter(description = "정렬 기준 (modified, meeting, title)") @RequestParam(defaultValue = "modified") String sortBy,
@Parameter(description = "정렬 방향 (asc, desc)") @RequestParam(defaultValue = "desc") String sortDir,
@Parameter(description = "상태 필터 (all, draft, complete)") @RequestParam(defaultValue = "all") String status,
@Parameter(description = "참여 유형 (attended, created)") @RequestParam(required = false) String participationType,
@Parameter(description = "검색 키워드") @RequestParam(required = false) String search) {
log.info("회의록 목록 조회 요청 - userId: {}, page: {}, size: {}", userId, page, size);
log.info("회의록 목록 조회 요청 - userId: {}, page: {}, size: {}, status: {}, participationType: {}, search: {}",
userId, page, size, status, participationType, search);
try {
// 캐시 확인
String cacheKey = String.format("minutes:list:%s:%d:%d:%s:%s", userId, page, size, sortBy, sortDir);
MinutesListResponse cachedResponse = cacheService.getCachedMinutesList(cacheKey);
if (cachedResponse != null) {
log.debug("캐시된 회의록 목록 반환 - userId: {}", userId);
return ResponseEntity.ok(ApiResponse.success(cachedResponse));
}
// 정렬 설정
Sort.Direction direction = sortDir.equalsIgnoreCase("desc") ? Sort.Direction.DESC : Sort.Direction.ASC;
Pageable pageable = PageRequest.of(page, size, Sort.by(direction, sortBy));
// 회의록 목록 조회
var minutesPage = minutesService.getMinutesListByUserId(userId, pageable);
// Mock 데이터 생성 (프론트엔드 테스트용)
List<MinutesListResponse.MinutesItem> mockMinutes = createMockMinutesList(userId);
// 응답 DTO 생성
List<MinutesListResponse.MinutesItem> minutesItems = minutesPage.getContent().stream()
.map(this::convertToMinutesItem)
// 필터링 적용
List<MinutesListResponse.MinutesItem> filteredMinutes = mockMinutes.stream()
.filter(item -> filterByStatus(item, status))
.filter(item -> filterByParticipationType(item, participationType, userId))
.filter(item -> filterBySearch(item, search))
.collect(Collectors.toList());
// 정렬 적용
applySorting(filteredMinutes, sortBy, sortDir);
// 페이징 적용
int startIndex = page * size;
int endIndex = Math.min(startIndex + size, filteredMinutes.size());
List<MinutesListResponse.MinutesItem> pagedMinutes =
startIndex < filteredMinutes.size() ?
filteredMinutes.subList(startIndex, endIndex) :
List.of();
// 통계 계산
MinutesListResponse.Statistics stats = calculateStatistics(mockMinutes, participationType, userId);
MinutesListResponse response = MinutesListResponse.builder()
.minutesList(minutesItems)
.totalCount(minutesPage.getTotalElements())
.minutesList(pagedMinutes)
.totalCount(filteredMinutes.size())
.currentPage(page)
.totalPages(minutesPage.getTotalPages())
.totalPages((int) Math.ceil((double) filteredMinutes.size() / size))
.statistics(stats)
.build();
// 캐시 저장
cacheService.cacheMinutesList(cacheKey, response);
log.info("회의록 목록 조회 성공 - userId: {}, count: {}", userId, minutesItems.size());
log.info("회의록 목록 조회 성공 - userId: {}, total: {}, filtered: {}, paged: {}",
userId, mockMinutes.size(), filteredMinutes.size(), pagedMinutes.size());
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
@@ -119,23 +126,13 @@ public class MinutesController {
log.info("회의록 상세 조회 요청 - userId: {}, minutesId: {}", userId, minutesId);
try {
// 캐시 확인
MinutesDetailResponse cachedResponse = cacheService.getCachedMinutesDetail(minutesId);
if (cachedResponse != null) {
log.debug("캐시된 회의록 상세 반환 - minutesId: {}", minutesId);
return ResponseEntity.ok(ApiResponse.success(cachedResponse));
}
// 회의록 조회
MinutesDTO minutesDTO = minutesService.getMinutesById(minutesId);
// 응답 DTO 생성
MinutesDetailResponse response = convertToMinutesDetailResponse(minutesDTO);
// Mock 데이터 생성 (프론트엔드 테스트용)
MinutesDetailResponse response = createMockMinutesDetail(minutesId, userId);
// 캐시 저장
cacheService.cacheMinutesDetail(minutesId, response);
log.info("회의록 상세 조회 성공 - minutesId: {}", minutesId);
log.info("회의록 상세 조회 성공 (Mock) - minutesId: {}", minutesId);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
@@ -345,74 +342,536 @@ public class MinutesController {
.build();
}
private MinutesDetailResponse convertToMinutesDetailResponse(MinutesDTO minutesDTO) {
/**
* 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;
}
/**
* 상태별 필터링
*/
private boolean filterByStatus(MinutesListResponse.MinutesItem item, String status) {
if ("all".equals(status)) {
return true;
}
if ("draft".equals(status)) {
return "DRAFT".equals(item.getStatus());
}
if ("complete".equals(status)) {
return "FINALIZED".equals(item.getStatus());
}
return true;
}
/**
* 참여 유형별 필터링
*/
private boolean filterByParticipationType(MinutesListResponse.MinutesItem item, String participationType, String userId) {
if (participationType == null || participationType.isEmpty()) {
return true;
}
if ("created".equals(participationType)) {
return item.isCreatedByUser();
}
if ("attended".equals(participationType)) {
return !item.isCreatedByUser();
}
return true;
}
/**
* 검색어 필터링
*/
private boolean filterBySearch(MinutesListResponse.MinutesItem item, String search) {
if (search == null || search.trim().isEmpty()) {
return true;
}
String searchLower = search.toLowerCase();
return item.getTitle().toLowerCase().contains(searchLower) ||
item.getMeetingTitle().toLowerCase().contains(searchLower);
}
/**
* 정렬 적용
*/
private void applySorting(List<MinutesListResponse.MinutesItem> items, String sortBy, String sortDir) {
boolean ascending = "asc".equalsIgnoreCase(sortDir);
switch (sortBy) {
case "title":
items.sort((a, b) -> ascending ?
a.getTitle().compareTo(b.getTitle()) :
b.getTitle().compareTo(a.getTitle()));
break;
case "meeting":
items.sort((a, b) -> ascending ?
a.getMeetingDate().compareTo(b.getMeetingDate()) :
b.getMeetingDate().compareTo(a.getMeetingDate()));
break;
case "modified":
default:
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 MinutesDetailResponse createMockMinutesDetail(String minutesId, String userId) {
return MinutesDetailResponse.builder()
.minutesId(minutesDTO.getMinutesId())
.title(minutesDTO.getTitle())
.memo(minutesDTO.getMemo())
.status(minutesDTO.getStatus())
.version(minutesDTO.getVersion())
.createdAt(minutesDTO.getCreatedAt())
.lastModifiedAt(minutesDTO.getLastModifiedAt())
.createdBy(minutesDTO.getCreatedBy())
.lastModifiedBy(minutesDTO.getLastModifiedBy())
.meeting(convertToMeetingInfo(minutesDTO.getMeeting()))
.sections(convertToSectionInfoList(minutesDTO.getSectionsInfo()))
.todos(convertToTodoInfoList(minutesDTO.getTodos()))
.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();
}
private MinutesDetailResponse.MeetingInfo convertToMeetingInfo(MinutesDTO.MeetingInfo meetingInfo) {
if (meetingInfo == null) return null;
/**
* Mock 회의 정보 생성 (프로토타입 기반)
*/
private MinutesDetailResponse.MeetingInfo createMockMeetingInfo() {
return MinutesDetailResponse.MeetingInfo.builder()
.meetingId(meetingInfo.getMeetingId())
.title(meetingInfo.getTitle())
.scheduledAt(meetingInfo.getScheduledAt())
.startedAt(meetingInfo.getStartedAt())
.endedAt(meetingInfo.getEndedAt())
.organizerId(meetingInfo.getOrganizerId())
.organizerName(meetingInfo.getOrganizerName())
.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();
}
private List<MinutesDetailResponse.SectionInfo> convertToSectionInfoList(
List<MinutesDTO.SectionInfo> sections) {
if (sections == null) return List.of();
return sections.stream()
.map(section -> MinutesDetailResponse.SectionInfo.builder()
.sectionId(section.getSectionId())
.title(section.getTitle())
.content(section.getContent())
.orderIndex(section.getOrderIndex())
.isLocked(section.isLocked())
.isVerified(section.isVerified())
.lockedBy(section.getLockedBy())
.lockedAt(section.getLockedAt())
.verifiedBy(section.getVerifiedBy())
.verifiedAt(section.getVerifiedAt())
.build())
.collect(Collectors.toList());
/**
* 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();
}
private List<MinutesDetailResponse.TodoInfo> convertToTodoInfoList(
List<MinutesDTO.TodoInfo> todos) {
if (todos == null) return List.of();
/**
* 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 todos.stream()
.map(todo -> MinutesDetailResponse.TodoInfo.builder()
.todoId(todo.getTodoId())
.title(todo.getTitle())
.description(todo.getDescription())
.assigneeId(todo.getAssigneeId())
.assigneeName(todo.getAssigneeName())
.priority(todo.getPriority())
.status(todo.getStatus())
.dueDate(todo.getDueDate())
.completedAt(todo.getCompletedAt())
.completedBy(todo.getCompletedBy())
.build())
.collect(Collectors.toList());
return MinutesDetailResponse.TodoProgress.builder()
.totalCount(todos.size())
.completedCount(completedCount)
.progressPercentage((completedCount * 100) / todos.size())
.todos(todos)
.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()
);
}
/**
* 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) {
// Mock 데이터로 대체 (프로토타입용)
return createMockMinutesDetail(minutesDTO.getMinutesId(), "user123");
}
}
@@ -19,8 +19,6 @@ public class DashboardResponse {
@Schema(description = "예정된 회의 목록")
private final List<UpcomingMeetingResponse> upcomingMeetings;
@Schema(description = "진행 중 Todo 목록")
private final List<ActiveTodoResponse> activeTodos;
@Schema(description = "최근 회의록 목록")
private final List<RecentMinutesResponse> myMinutes;
@@ -36,9 +34,6 @@ public class DashboardResponse {
.upcomingMeetings(dto.getUpcomingMeetings().stream()
.map(UpcomingMeetingResponse::from)
.toList())
.activeTodos(dto.getActiveTodos().stream()
.map(ActiveTodoResponse::from)
.toList())
.myMinutes(dto.getMyMinutes().stream()
.map(RecentMinutesResponse::from)
.toList())
@@ -84,39 +79,6 @@ public class DashboardResponse {
}
}
@Getter
@Builder
@Schema(description = "진행 중 Todo 정보")
public static class ActiveTodoResponse {
@Schema(description = "Todo ID", example = "660e8400-e29b-41d4-a716-446655440000")
private final String todoId;
@Schema(description = "Todo 내용", example = "API 설계 문서 작성")
private final String content;
@Schema(description = "마감일", example = "2025-01-30")
private final String dueDate;
@Schema(description = "우선순위", example = "HIGH")
private final String priority;
@Schema(description = "Todo 상태", example = "IN_PROGRESS")
private final String status;
@Schema(description = "회의록 ID", example = "770e8400-e29b-41d4-a716-446655440000")
private final String minutesId;
public static ActiveTodoResponse from(DashboardDTO.ActiveTodoDTO dto) {
return ActiveTodoResponse.builder()
.todoId(dto.getTodoId())
.content(dto.getContent())
.dueDate(dto.getDueDate())
.priority(dto.getPriority())
.status(dto.getStatus())
.minutesId(dto.getMinutesId())
.build();
}
}
@Getter
@Builder
@@ -159,8 +121,6 @@ public class DashboardResponse {
@Schema(description = "예정된 회의 수", example = "2")
private final Integer upcomingMeetingsCount;
@Schema(description = "진행 중 Todo 수", example = "5")
private final Integer activeTodosCount;
@Schema(description = "Todo 완료율", example = "68.5")
private final Double todoCompletionRate;
@@ -168,7 +128,6 @@ public class DashboardResponse {
public static StatisticsResponse from(DashboardDTO.StatisticsDTO dto) {
return StatisticsResponse.builder()
.upcomingMeetingsCount(dto.getUpcomingMeetingsCount())
.activeTodosCount(dto.getActiveTodosCount())
.todoCompletionRate(dto.getTodoCompletionRate())
.build();
}
@@ -9,7 +9,7 @@ import java.time.LocalDateTime;
import java.util.List;
/**
* 회의록 상세 조회 응답 DTO
* 회의록 상세 조회 응답 DTO (프로토타입 기반 - 대시보드/회의록 탭 구조)
*/
@Getter
@Builder
@@ -27,14 +27,14 @@ public class MinutesDetailResponse {
private String createdBy;
private String lastModifiedBy;
// 회의 정보
// 회의 기본 정보
private MeetingInfo meeting;
// 섹션 목록
private List<SectionInfo> sections;
// 대시보드 탭 정보
private DashboardInfo dashboard;
// Todo 목록
private List<TodoInfo> todos;
// 회의록 탭 정보 (안건별 상세)
private List<AgendaInfo> agendas;
@Getter
@Builder
@@ -48,39 +48,143 @@ public class MinutesDetailResponse {
private LocalDateTime endedAt;
private String organizerId;
private String organizerName;
private String location;
private int durationMinutes;
private List<Participant> participants;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SectionInfo {
private String sectionId;
private String title;
public static class Participant {
private String userId;
private String name;
private String role; // 작성자, 참여자
private String avatarColor; // avatar-green, avatar-blue, etc.
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DashboardInfo {
private List<KeyPoint> keyPoints; // 핵심내용
private List<String> keywords; // 키워드 태그
private Statistics stats; // 통계 정보
private List<Decision> decisions; // 결정사항
private TodoProgress todoProgress; // Todo 진행상황
private List<RelatedMinutes> relatedMinutes; // 관련회의록
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class KeyPoint {
private int index;
private String content;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Statistics {
private int participantCount;
private int durationMinutes;
private int agendaCount;
private int todoCount;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Decision {
private String content;
private String decidedBy;
private LocalDateTime decidedAt;
private String background;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TodoProgress {
private int totalCount;
private int completedCount;
private int progressPercentage;
private List<SimpleTodo> todos; // 간단한 Todo 목록 (한줄 텍스트)
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class SimpleTodo {
private String todoId;
private String title; // 간단한 한줄 텍스트
private String assigneeName;
private String status; // IN_PROGRESS, COMPLETED, OVERDUE
private String priority; // HIGH, MEDIUM, LOW
private LocalDateTime dueDate;
private String dueDayStatus; // D-2, D+1, 완료 등
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class RelatedMinutes {
private String minutesId;
private String title;
private LocalDateTime meetingDate;
private String author;
private int relevancePercentage;
private String relevanceLevel; // HIGH, MEDIUM, LOW
private String summary;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AgendaInfo {
private String agendaId;
private String title;
private int orderIndex;
private boolean isLocked;
private boolean isVerified;
private String lockedBy;
private LocalDateTime lockedAt;
private String verifiedBy;
private LocalDateTime verifiedAt;
// AI 요약
private AiSummary aiSummary;
// 안건 상세 내용
private AgendaDetails details;
// 관련회의록
private List<RelatedMinutes> relatedMinutes;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TodoInfo {
private String todoId;
private String title;
private String description;
private String assigneeId;
private String assigneeName;
private String priority;
private String status;
private LocalDateTime dueDate;
private LocalDateTime completedAt;
private String completedBy;
public static class AiSummary {
private String content;
private LocalDateTime generatedAt;
private LocalDateTime modifiedAt;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AgendaDetails {
private List<String> discussions; // 논의 사항
private List<String> decisions; // 결정 사항
}
}
@@ -21,6 +21,17 @@ public class MinutesListResponse {
private long totalCount;
private int currentPage;
private int totalPages;
private Statistics statistics; // 상태별 통계
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Statistics {
private long totalCount;
private long draftCount;
private long completeCount;
}
@Getter
@Builder
@@ -34,9 +45,13 @@ public class MinutesListResponse {
private int version;
private LocalDateTime createdAt;
private LocalDateTime lastModifiedAt;
private LocalDateTime meetingDate; // 회의 일시
private String createdBy;
private String lastModifiedBy;
private int participantCount; // 참석자 수
private int todoCount;
private int completedTodoCount;
private int completionRate; // 검증완료율
private boolean isCreatedByUser; // 사용자가 생성한 회의록 여부
}
}
@@ -1,8 +1,14 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.Dashboard;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
import com.unicorn.hgzero.meeting.biz.usecase.out.DashboardReader;
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.TodoEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingParticipantJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MinutesJpaRepository;
import com.unicorn.hgzero.meeting.infra.gateway.repository.TodoJpaRepository;
import lombok.RequiredArgsConstructor;
@@ -11,6 +17,11 @@ import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 대시보드 Gateway 구현체
@@ -22,106 +33,227 @@ import java.time.LocalDateTime;
public class DashboardGateway implements DashboardReader {
private final MeetingJpaRepository meetingJpaRepository;
private final MeetingParticipantJpaRepository meetingParticipantJpaRepository;
private final MinutesJpaRepository minutesJpaRepository;
private final TodoJpaRepository todoJpaRepository;
@Override
public Dashboard getDashboardByUserId(String userId) {
log.debug("Getting dashboard for user: {}", userId);
log.info("대시보드 데이터 조회 시작 - userId: {}", userId);
// 회의 통계 조회
long totalMeetings = meetingJpaRepository.findByOrganizerId(userId).size();
long scheduledMeetings = meetingJpaRepository.findByOrganizerIdAndStatus(userId, "SCHEDULED").size();
long inProgressMeetings = meetingJpaRepository.findByOrganizerIdAndStatus(userId, "IN_PROGRESS").size();
long completedMeetings = meetingJpaRepository.findByOrganizerIdAndStatus(userId, "COMPLETED").size();
// 1. 다가오는 회의 목록 조회 (향후 30일, 최대 10개)
List<Meeting> upcomingMeetings = getUpcomingMeetings(userId);
// 2. 최근 회의록 목록 조회 (최근 7일, 최대 10개)
List<Minutes> recentMinutes = getRecentMinutes(userId);
// 3. 통계 정보 계산 (최근 30일 기준)
Dashboard.Statistics statistics = calculateStatistics(userId);
// 회의록 통계 조회
long totalMinutes = minutesJpaRepository.findByCreatedBy(userId).size();
long draftMinutes = minutesJpaRepository.findByCreatedBy(userId).stream()
.filter(m -> "DRAFT".equals(m.getStatus()))
.count();
long finalizedMinutes = minutesJpaRepository.findByCreatedBy(userId).stream()
.filter(m -> "FINALIZED".equals(m.getStatus()))
.count();
// Todo 통계 조회
long totalTodos = todoJpaRepository.findByAssigneeId(userId).size();
long pendingTodos = todoJpaRepository.findByAssigneeIdAndStatus(userId, "PENDING").size();
long completedTodos = todoJpaRepository.findByAssigneeIdAndStatus(userId, "COMPLETED").size();
long overdueTodos = todoJpaRepository.findByAssigneeId(userId).stream()
.filter(todo -> todo.getDueDate() != null
&& LocalDate.now().isAfter(todo.getDueDate())
&& !"COMPLETED".equals(todo.getStatus()))
.count();
// 통계 객체 생성
Dashboard.Statistics statistics = Dashboard.Statistics.builder()
.totalMeetings((int) totalMeetings)
.scheduledMeetings((int) scheduledMeetings)
.inProgressMeetings((int) inProgressMeetings)
.completedMeetings((int) completedMeetings)
.totalMinutes((int) totalMinutes)
.draftMinutes((int) draftMinutes)
.finalizedMinutes((int) finalizedMinutes)
.totalTodos((int) totalTodos)
.pendingTodos((int) pendingTodos)
.completedTodos((int) completedTodos)
.overdueTodos((int) overdueTodos)
.build();
// 대시보드 생성
return Dashboard.builder()
Dashboard dashboard = Dashboard.builder()
.userId(userId)
.period("7days")
.upcomingMeetings(upcomingMeetings)
.recentMinutes(recentMinutes)
.assignedTodos(new ArrayList<>())
.statistics(statistics)
.build();
log.info("대시보드 데이터 조회 완료 - userId: {}, 예정 회의: {}개, 최근 회의록: {}개",
userId, upcomingMeetings.size(), recentMinutes.size());
return dashboard;
}
@Override
public Dashboard getDashboardByUserIdAndPeriod(String userId, String period) {
log.debug("Getting dashboard for user: {} with period: {}", userId, period);
log.info("기간별 대시보드 데이터 조회 시작 - userId: {}, period: {}", userId, period);
// 기간 계산
// 기간에 따른 조회 범위 계산
LocalDateTime startTime = calculateStartTime(period);
LocalDateTime endTime = LocalDateTime.now();
// 기간 내 회의 통계 조회
long totalMeetings = meetingJpaRepository.findByOrganizerId(userId).stream()
.filter(m -> m.getScheduledAt().isAfter(startTime) && m.getScheduledAt().isBefore(endTime))
// 1. 기간 내 다가오는 회의 목록 조회
List<Meeting> upcomingMeetings = getUpcomingMeetingsByPeriod(userId, startTime, endTime);
// 2. 기간 내 최근 회의록 목록 조회
List<Minutes> recentMinutes = getRecentMinutesByPeriod(userId, startTime, endTime);
// 3. 기간별 통계 정보 계산
Dashboard.Statistics statistics = calculateStatisticsByPeriod(userId, startTime, endTime);
Dashboard dashboard = Dashboard.builder()
.userId(userId)
.period(period)
.upcomingMeetings(upcomingMeetings)
.recentMinutes(recentMinutes)
.assignedTodos(new ArrayList<>())
.statistics(statistics)
.build();
log.info("기간별 대시보드 데이터 조회 완료 - userId: {}, period: {}", userId, period);
return dashboard;
}
/**
* 다가오는 회의 목록 조회
*/
private List<Meeting> getUpcomingMeetings(String userId) {
LocalDateTime now = LocalDateTime.now();
LocalDateTime endTime = now.plusDays(30); // 향후 30일
return getUpcomingMeetingsByPeriod(userId, now, endTime);
}
/**
* 기간별 다가오는 회의 목록 조회
*/
private List<Meeting> getUpcomingMeetingsByPeriod(String userId, LocalDateTime startTime, LocalDateTime endTime) {
Set<String> userMeetingIds = new HashSet<>();
// 주최자로 참여하는 예정/진행중 회의 조회
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()))
.toList();
organizerMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId()));
// 참석자로 참여하는 예정/진행중 회의 조회
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()))
.toList();
participantMeetings.forEach(m -> userMeetingIds.add(m.getMeetingId()));
// 중복 제거된 회의 목록을 시간순 정렬하여 최대 10개만 반환
return 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)
.map(MeetingEntity::toDomain)
.collect(Collectors.toList());
}
/**
* 최근 회의록 목록 조회
*/
private List<Minutes> getRecentMinutes(String userId) {
LocalDateTime startTime = LocalDateTime.now().minusDays(7);
return getRecentMinutesByPeriod(userId, startTime, LocalDateTime.now());
}
/**
* 기간별 최근 회의록 목록 조회
*/
private List<Minutes> getRecentMinutesByPeriod(String userId, LocalDateTime startTime, LocalDateTime endTime) {
Set<String> userMinutesIds = new HashSet<>();
// 작성자로 참여한 회의록 조회
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()))
.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)
.map(MinutesEntity::toDomain)
.collect(Collectors.toList());
}
/**
* 통계 정보 계산
*/
private Dashboard.Statistics calculateStatistics(String userId) {
LocalDateTime startTime = LocalDateTime.now().minusDays(30); // 최근 30일
LocalDateTime endTime = LocalDateTime.now();
return calculateStatisticsByPeriod(userId, startTime, endTime);
}
/**
* 기간별 통계 정보 계산
*/
private Dashboard.Statistics calculateStatisticsByPeriod(String userId, LocalDateTime startTime, LocalDateTime endTime) {
// 사용자가 관련된 모든 회의 ID 수집
Set<String> userMeetingIds = new HashSet<>();
// 주최자로 참여한 회의
meetingJpaRepository.findByOrganizerId(userId).forEach(m -> userMeetingIds.add(m.getMeetingId()));
// 참석자로 참여한 회의
meetingParticipantJpaRepository.findByUserId(userId).stream()
.map(p -> p.getMeetingId())
.forEach(userMeetingIds::add);
// 기간 내 회의 통계
List<MeetingEntity> periodMeetings = meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.filter(m -> userMeetingIds.contains(m.getMeetingId()))
.toList();
long totalMeetings = periodMeetings.size();
long scheduledMeetings = periodMeetings.stream().filter(m -> "SCHEDULED".equals(m.getStatus())).count();
long inProgressMeetings = periodMeetings.stream().filter(m -> "IN_PROGRESS".equals(m.getStatus())).count();
long completedMeetings = periodMeetings.stream().filter(m -> "COMPLETED".equals(m.getStatus())).count();
// 회의록 통계 (사용자가 관련된 모든 회의록)
Set<String> userMinutesIds = new HashSet<>();
// 작성자로 참여한 회의록
minutesJpaRepository.findByCreatedBy(userId).forEach(m -> userMinutesIds.add(m.getMinutesId()));
// 참석한 회의의 회의록
userMeetingIds.forEach(meetingId -> {
minutesJpaRepository.findByMeetingId(meetingId).forEach(m -> userMinutesIds.add(m.getMinutesId()));
});
List<MinutesEntity> userMinutes = minutesJpaRepository.findAll().stream()
.filter(m -> userMinutesIds.contains(m.getMinutesId()))
.toList();
long totalMinutes = userMinutes.size();
long draftMinutes = userMinutes.stream().filter(m -> "DRAFT".equals(m.getStatus())).count();
long finalizedMinutes = userMinutes.stream().filter(m -> "FINALIZED".equals(m.getStatus())).count();
// Todo 통계
List<TodoEntity> userTodos = todoJpaRepository.findByAssigneeId(userId);
long totalTodos = userTodos.size();
long pendingTodos = userTodos.stream().filter(t -> "PENDING".equals(t.getStatus())).count();
long completedTodos = userTodos.stream().filter(t -> "COMPLETED".equals(t.getStatus())).count();
long overdueTodos = userTodos.stream()
.filter(t -> t.getDueDate() != null
&& LocalDate.now().isAfter(t.getDueDate())
&& !"COMPLETED".equals(t.getStatus()))
.count();
long scheduledMeetings = meetingJpaRepository.findByOrganizerIdAndStatus(userId, "SCHEDULED").stream()
.filter(m -> m.getScheduledAt().isAfter(startTime) && m.getScheduledAt().isBefore(endTime))
.count();
long inProgressMeetings = meetingJpaRepository.findByOrganizerIdAndStatus(userId, "IN_PROGRESS").stream()
.filter(m -> m.getScheduledAt().isAfter(startTime) && m.getScheduledAt().isBefore(endTime))
.count();
long completedMeetings = meetingJpaRepository.findByOrganizerIdAndStatus(userId, "COMPLETED").stream()
.filter(m -> m.getScheduledAt().isAfter(startTime) && m.getScheduledAt().isBefore(endTime))
.count();
// 회의록 통계 조회 (전체 기간)
long totalMinutes = minutesJpaRepository.findByCreatedBy(userId).size();
long draftMinutes = minutesJpaRepository.findByCreatedBy(userId).stream()
.filter(m -> "DRAFT".equals(m.getStatus()))
.count();
long finalizedMinutes = minutesJpaRepository.findByCreatedBy(userId).stream()
.filter(m -> "FINALIZED".equals(m.getStatus()))
.count();
// Todo 통계 조회 (전체 기간)
long totalTodos = todoJpaRepository.findByAssigneeId(userId).size();
long pendingTodos = todoJpaRepository.findByAssigneeIdAndStatus(userId, "PENDING").size();
long completedTodos = todoJpaRepository.findByAssigneeIdAndStatus(userId, "COMPLETED").size();
long overdueTodos = todoJpaRepository.findByAssigneeId(userId).stream()
.filter(todo -> todo.getDueDate() != null
&& LocalDate.now().isAfter(todo.getDueDate())
&& !"COMPLETED".equals(todo.getStatus()))
.count();
// 통계 객체 생성
Dashboard.Statistics statistics = Dashboard.Statistics.builder()
return Dashboard.Statistics.builder()
.totalMeetings((int) totalMeetings)
.scheduledMeetings((int) scheduledMeetings)
.inProgressMeetings((int) inProgressMeetings)
@@ -134,13 +266,6 @@ public class DashboardGateway implements DashboardReader {
.completedTodos((int) completedTodos)
.overdueTodos((int) overdueTodos)
.build();
// 대시보드 생성
return Dashboard.builder()
.userId(userId)
.period(period)
.statistics(statistics)
.build();
}
/**
@@ -149,12 +274,17 @@ public class DashboardGateway implements DashboardReader {
private LocalDateTime calculateStartTime(String period) {
LocalDateTime now = LocalDateTime.now();
return switch (period.toUpperCase()) {
case "WEEK" -> now.minusWeeks(1);
case "MONTH" -> now.minusMonths(1);
case "QUARTER" -> now.minusMonths(3);
case "YEAR" -> now.minusYears(1);
default -> now.minusMonths(1); // 기본값: 1개월
return switch (period.toLowerCase()) {
case "1day" -> now.minusDays(1);
case "3days" -> now.minusDays(3);
case "7days" -> now.minusDays(7);
case "30days" -> now.minusDays(30);
case "90days" -> now.minusDays(90);
case "week" -> now.minusWeeks(1);
case "month" -> now.minusMonths(1);
case "quarter" -> now.minusMonths(3);
case "year" -> now.minusYears(1);
default -> now.minusDays(7); // 기본값: 7일
};
}
}
@@ -66,6 +66,8 @@ public class MinutesEntity extends BaseTimeEntity {
.status(this.status)
.version(this.version)
.createdBy(this.createdBy)
.createdAt(this.getCreatedAt())
.lastModifiedAt(this.getUpdatedAt())
.finalizedBy(this.finalizedBy)
.finalizedAt(this.finalizedAt)
.build();
@@ -66,6 +66,8 @@ public class TodoEntity extends BaseTimeEntity {
.dueDate(this.dueDate)
.status(this.status)
.priority(this.priority)
.createdAt(this.getCreatedAt())
.lastModifiedAt(this.getUpdatedAt())
.completedAt(this.completedAt)
.build();
}
@@ -0,0 +1,117 @@
package com.unicorn.hgzero.meeting.infra.mapper;
import com.unicorn.hgzero.meeting.biz.domain.Dashboard;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
import com.unicorn.hgzero.meeting.infra.dto.response.DashboardResponse;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* Dashboard 도메인 객체를 Response DTO로 변환하는 매퍼
*/
@Component
public class DashboardResponseMapper {
/**
* Dashboard 도메인 객체를 DashboardResponse로 변환
*/
public DashboardResponse toResponse(Dashboard dashboard) {
if (dashboard == null) {
return null;
}
return DashboardResponse.builder()
.upcomingMeetings(toUpcomingMeetingResponses(dashboard.getUpcomingMeetings()))
.myMinutes(toRecentMinutesResponses(dashboard.getRecentMinutes()))
.statistics(toStatisticsResponse(dashboard.getStatistics()))
.build();
}
/**
* Meeting 목록을 UpcomingMeetingResponse 목록으로 변환
*/
private List<DashboardResponse.UpcomingMeetingResponse> toUpcomingMeetingResponses(List<Meeting> meetings) {
if (meetings == null || meetings.isEmpty()) {
return Collections.emptyList();
}
return meetings.stream()
.map(this::toUpcomingMeetingResponse)
.collect(Collectors.toList());
}
/**
* Meeting을 UpcomingMeetingResponse로 변환
*/
private DashboardResponse.UpcomingMeetingResponse toUpcomingMeetingResponse(Meeting meeting) {
return DashboardResponse.UpcomingMeetingResponse.builder()
.meetingId(meeting.getMeetingId())
.title(meeting.getTitle())
.startTime(meeting.getScheduledAt())
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0)
.status(meeting.getStatus())
.build();
}
/**
* Minutes 목록을 RecentMinutesResponse 목록으로 변환
*/
private List<DashboardResponse.RecentMinutesResponse> toRecentMinutesResponses(List<Minutes> minutesList) {
if (minutesList == null || minutesList.isEmpty()) {
return Collections.emptyList();
}
return minutesList.stream()
.map(this::toRecentMinutesResponse)
.collect(Collectors.toList());
}
/**
* Minutes를 RecentMinutesResponse로 변환
*/
private DashboardResponse.RecentMinutesResponse toRecentMinutesResponse(Minutes minutes) {
return DashboardResponse.RecentMinutesResponse.builder()
.minutesId(minutes.getMinutesId())
.title(minutes.getTitle())
.meetingDate(minutes.getCreatedAt())
.status(minutes.getStatus())
.participantCount(0) // Meeting 정보가 필요한데 현재 Minutes에 직접적인 참석자 정보가 없음
.lastModified(minutes.getLastModifiedAt() != null ?
minutes.getLastModifiedAt() : minutes.getCreatedAt())
.build();
}
/**
* Dashboard.Statistics를 StatisticsResponse로 변환
*/
private DashboardResponse.StatisticsResponse toStatisticsResponse(Dashboard.Statistics statistics) {
if (statistics == null) {
return DashboardResponse.StatisticsResponse.builder()
.upcomingMeetingsCount(0)
.todoCompletionRate(0.0)
.build();
}
// Todo 완료율 계산
double todoCompletionRate = 0.0;
int totalTodos = statistics.getTotalTodos() != null ? statistics.getTotalTodos() : 0;
int completedTodos = statistics.getCompletedTodos() != null ? statistics.getCompletedTodos() : 0;
if (totalTodos > 0) {
todoCompletionRate = (double) completedTodos / totalTodos * 100.0;
}
return DashboardResponse.StatisticsResponse.builder()
.upcomingMeetingsCount(statistics.getScheduledMeetings() != null ?
statistics.getScheduledMeetings() : 0)
.todoCompletionRate(todoCompletionRate)
.build();
}
}