feat: Meeting Service AI 통합 API 개발 완료

## 구현 내용
- 참석자별 회의록 조회 API (GET /api/meetings/{meetingId}/ai/participant-minutes)
- 안건별 섹션 조회 API (GET /api/meetings/{meetingId}/ai/agenda-sections)
- 회의 통계 조회 API (GET /api/meetings/{meetingId}/ai/statistics)

## DB 스키마 변경
- V4 마이그레이션: agenda_sections 테이블에 todos JSON 컬럼 추가
- AI가 추출한 Todo를 안건별로 저장하는 구조

## 주요 특징
- AI Service가 한 번에 요약 + Todo 추출
- 프로토타입 기반 요구사항 반영 (불필요한 통계 제거)
- Todo 수를 agenda_sections의 todos 컬럼에서 집계

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Minseo-Jo 2025-10-28 14:22:59 +09:00
parent 92e4863fc7
commit 79036128ec
18 changed files with 1164 additions and 0 deletions

View File

@ -0,0 +1,138 @@
package com.unicorn.hgzero.meeting.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 안건별 회의록 섹션 도메인 모델
* AI 요약 결과를 안건별로 구조화하여 저장
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgendaSection {
/**
* 섹션 고유 ID
*/
private String id;
/**
* 회의록 ID (통합 회의록 참조)
*/
private String minutesId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 안건 번호 (1, 2, 3...)
*/
private Integer agendaNumber;
/**
* 안건 제목
*/
private String agendaTitle;
/**
* AI 생성 짧은 요약 (1줄, 20자 이내)
*/
private String aiSummaryShort;
/**
* 논의 사항 (핵심 내용 3-5문장)
*/
private String discussions;
/**
* 결정 사항 목록
*/
private List<String> decisions;
/**
* 보류 사항 목록
*/
private List<String> pendingItems;
/**
* 참석자별 의견
*/
private List<ParticipantOpinion> opinions;
/**
* AI 추출 Todo 목록
*/
private List<TodoItem> todos;
/**
* 생성 시간
*/
private LocalDateTime createdAt;
/**
* 수정 시간
*/
private LocalDateTime updatedAt;
/**
* 참석자 의견 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ParticipantOpinion {
/**
* 발언자 이름
*/
private String speaker;
/**
* 의견 내용
*/
private String opinion;
}
/**
* Todo 항목 내부 클래스
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class TodoItem {
/**
* Todo 제목
*/
private String title;
/**
* 담당자
*/
private String assignee;
/**
* 마감일 (YYYY-MM-DD)
*/
private String dueDate;
/**
* 설명
*/
private String description;
/**
* 우선순위 (HIGH, MEDIUM, LOW)
*/
private String priority;
}
}

View File

@ -31,6 +31,11 @@ public class Minutes {
*/
private String meetingId;
/**
* 작성자 사용자 ID (NULL: AI 통합 회의록, NOT NULL: 참석자별 회의록)
*/
private String userId;
/**
* 회의록 제목
*/

View File

@ -0,0 +1,69 @@
package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 안건별 회의록 섹션 서비스
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class AgendaSectionService {
private final AgendaSectionReader agendaSectionReader;
private final AgendaSectionWriter agendaSectionWriter;
/**
* 회의 ID로 안건별 섹션 조회
*
* @param meetingId 회의 ID
* @return 안건별 섹션 목록 (안건 번호 정렬)
*/
public List<AgendaSection> getAgendaSectionsByMeeting(String meetingId) {
log.info("회의 ID로 안건별 섹션 조회: {}", meetingId);
return agendaSectionReader.findByMeetingId(meetingId);
}
/**
* 회의록 ID로 안건별 섹션 조회
*
* @param minutesId 회의록 ID
* @return 안건별 섹션 목록
*/
public List<AgendaSection> getAgendaSectionsByMinutes(String minutesId) {
log.info("회의록 ID로 안건별 섹션 조회: {}", minutesId);
return agendaSectionReader.findByMinutesId(minutesId);
}
/**
* 안건별 섹션 일괄 저장
*
* @param sections 안건 섹션 목록
* @return 저장된 안건 섹션 목록
*/
@Transactional
public List<AgendaSection> saveAgendaSections(List<AgendaSection> sections) {
log.info("안건별 섹션 일괄 저장: {} 개", sections.size());
return agendaSectionWriter.saveAll(sections);
}
/**
* 회의 ID로 안건별 섹션 전체 삭제
*
* @param meetingId 회의 ID
*/
@Transactional
public void deleteAgendaSectionsByMeeting(String meetingId) {
log.info("회의 ID로 안건별 섹션 전체 삭제: {}", meetingId);
agendaSectionWriter.deleteByMeetingId(meetingId);
}
}

View File

@ -358,4 +358,30 @@ public class MinutesService implements
.memo("") // 메모 필드는 추후 구현
.build();
}
/**
* 회의 ID로 참석자별 회의록 조회
* AI Service가 통합 회의록 생성 사용
*
* @param meetingId 회의 ID
* @return 참석자별 회의록 목록 (user_id IS NOT NULL)
*/
@Transactional(readOnly = true)
public List<Minutes> getParticipantMinutesByMeeting(String meetingId) {
log.info("회의 ID로 참석자별 회의록 조회: {}", meetingId);
return minutesReader.findParticipantMinutesByMeetingId(meetingId);
}
/**
* 회의 ID로 AI 통합 회의록 조회
*
* @param meetingId 회의 ID
* @return AI 통합 회의록 (user_id IS NULL)
*/
@Transactional(readOnly = true)
public Minutes getConsolidatedMinutesByMeeting(String meetingId) {
log.info("회의 ID로 AI 통합 회의록 조회: {}", meetingId);
return minutesReader.findConsolidatedMinutesByMeetingId(meetingId)
.orElse(null);
}
}

View File

@ -0,0 +1,45 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import java.util.List;
import java.util.Optional;
/**
* 안건별 회의록 섹션 조회 인터페이스
*/
public interface AgendaSectionReader {
/**
* 회의 ID로 안건별 섹션 조회
*
* @param meetingId 회의 ID
* @return 안건별 섹션 목록 (안건 번호 정렬)
*/
List<AgendaSection> findByMeetingId(String meetingId);
/**
* 회의록 ID로 안건별 섹션 조회
*
* @param minutesId 회의록 ID
* @return 안건별 섹션 목록
*/
List<AgendaSection> findByMinutesId(String minutesId);
/**
* 섹션 ID로 조회
*
* @param id 섹션 ID
* @return 안건 섹션
*/
Optional<AgendaSection> findById(String id);
/**
* 회의 ID와 안건 번호로 섹션 조회
*
* @param meetingId 회의 ID
* @param agendaNumber 안건 번호
* @return 안건 섹션
*/
Optional<AgendaSection> findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber);
}

View File

@ -0,0 +1,41 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import java.util.List;
/**
* 안건별 회의록 섹션 저장 인터페이스
*/
public interface AgendaSectionWriter {
/**
* 안건별 섹션 저장
*
* @param section 안건 섹션
* @return 저장된 안건 섹션
*/
AgendaSection save(AgendaSection section);
/**
* 안건별 섹션 일괄 저장
*
* @param sections 안건 섹션 목록
* @return 저장된 안건 섹션 목록
*/
List<AgendaSection> saveAll(List<AgendaSection> sections);
/**
* 안건별 섹션 삭제
*
* @param id 섹션 ID
*/
void delete(String id);
/**
* 회의 ID로 안건별 섹션 전체 삭제
*
* @param meetingId 회의 ID
*/
void deleteByMeetingId(String meetingId);
}

View File

@ -39,4 +39,21 @@ public interface MinutesReader {
* 확정자 ID로 회의록 목록 조회
*/
List<Minutes> findByFinalizedBy(String finalizedBy);
/**
* 회의 ID로 참석자별 회의록 조회 (user_id IS NOT NULL)
* AI Service가 통합 회의록 생성 사용
*
* @param meetingId 회의 ID
* @return 참석자별 회의록 목록
*/
List<Minutes> findParticipantMinutesByMeetingId(String meetingId);
/**
* 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL)
*
* @param meetingId 회의 ID
* @return AI 통합 회의록
*/
Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId);
}

View File

@ -0,0 +1,233 @@
package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.service.AgendaSectionService;
import com.unicorn.hgzero.meeting.biz.service.MeetingService;
import com.unicorn.hgzero.meeting.biz.service.MinutesService;
import com.unicorn.hgzero.meeting.infra.dto.response.ParticipantMinutesResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.AgendaSectionResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MeetingStatisticsResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* 회의 AI 기능 API Controller
* 회의 종료 AI 통합 회의록 생성을 위한 API
*/
@RestController
@RequestMapping("/api/meetings")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Meeting AI", description = "회의 AI 통합 기능 API")
public class MeetingAiController {
private final MinutesService minutesService;
private final AgendaSectionService agendaSectionService;
private final MeetingService meetingService;
@GetMapping("/{meetingId}/ai/participant-minutes")
@Operation(
summary = "참석자별 회의록 조회",
description = "특정 회의의 모든 참석자가 작성한 회의록을 조회합니다. AI Service가 통합 회의록 생성 시 사용합니다."
)
public ResponseEntity<ApiResponse<ParticipantMinutesResponse>> getParticipantMinutes(
@Parameter(description = "회의 ID") @PathVariable String meetingId,
@RequestHeader("X-User-Id") String userId) {
log.info("참석자별 회의록 조회 요청 - meetingId: {}, userId: {}", meetingId, userId);
try {
List<Minutes> participantMinutes = minutesService.getParticipantMinutesByMeeting(meetingId);
List<ParticipantMinutesResponse.ParticipantMinutesItem> items = participantMinutes.stream()
.map(this::convertToParticipantMinutesItem)
.collect(Collectors.toList());
ParticipantMinutesResponse response = ParticipantMinutesResponse.builder()
.meetingId(meetingId)
.participantMinutes(items)
.build();
log.info("참석자별 회의록 조회 성공 - meetingId: {}, count: {}", meetingId, items.size());
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("참석자별 회의록 조회 실패 - meetingId: {}", meetingId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("참석자별 회의록 조회에 실패했습니다"));
}
}
@GetMapping("/{meetingId}/ai/agenda-sections")
@Operation(
summary = "안건별 섹션 조회",
description = "특정 회의의 안건별 AI 요약 섹션을 조회합니다. 회의 종료 화면에서 사용합니다."
)
public ResponseEntity<ApiResponse<AgendaSectionResponse>> getAgendaSections(
@Parameter(description = "회의 ID") @PathVariable String meetingId,
@RequestHeader("X-User-Id") String userId) {
log.info("안건별 섹션 조회 요청 - meetingId: {}, userId: {}", meetingId, userId);
try {
List<AgendaSection> sections = agendaSectionService.getAgendaSectionsByMeeting(meetingId);
List<AgendaSectionResponse.AgendaSectionItem> items = sections.stream()
.map(this::convertToAgendaSectionItem)
.collect(Collectors.toList());
AgendaSectionResponse response = AgendaSectionResponse.builder()
.meetingId(meetingId)
.sections(items)
.build();
log.info("안건별 섹션 조회 성공 - meetingId: {}, count: {}", meetingId, items.size());
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("안건별 섹션 조회 실패 - meetingId: {}", meetingId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("안건별 섹션 조회에 실패했습니다"));
}
}
@GetMapping("/{meetingId}/ai/statistics")
@Operation(
summary = "회의 통계 조회",
description = "특정 회의의 통계 정보를 조회합니다. 회의 종료 화면에서 사용합니다."
)
public ResponseEntity<ApiResponse<MeetingStatisticsResponse>> getMeetingStatistics(
@Parameter(description = "회의 ID") @PathVariable String meetingId,
@RequestHeader("X-User-Id") String userId) {
log.info("회의 통계 조회 요청 - meetingId: {}, userId: {}", meetingId, userId);
try {
MeetingStatisticsResponse response = buildMeetingStatistics(meetingId);
log.info("회의 통계 조회 성공 - meetingId: {}", meetingId);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("회의 통계 조회 실패 - meetingId: {}", meetingId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("회의 통계 조회에 실패했습니다"));
}
}
private ParticipantMinutesResponse.ParticipantMinutesItem convertToParticipantMinutesItem(Minutes minutes) {
List<ParticipantMinutesResponse.ParticipantMinutesItem.MinutesSection> sections = null;
if (minutes.getSections() != null) {
sections = minutes.getSections().stream()
.map(this::convertToMinutesSection)
.collect(Collectors.toList());
}
return ParticipantMinutesResponse.ParticipantMinutesItem.builder()
.minutesId(minutes.getMinutesId())
.userId(minutes.getUserId())
.title(minutes.getTitle())
.status(minutes.getStatus())
.version(minutes.getVersion())
.createdBy(minutes.getCreatedBy())
.createdAt(minutes.getCreatedAt())
.lastModifiedAt(minutes.getLastModifiedAt())
.sections(sections)
.build();
}
private ParticipantMinutesResponse.ParticipantMinutesItem.MinutesSection convertToMinutesSection(MinutesSection section) {
return ParticipantMinutesResponse.ParticipantMinutesItem.MinutesSection.builder()
.sectionId(section.getSectionId())
.title(section.getTitle())
.type(section.getType())
.content(section.getContent())
.orderIndex(section.getOrder())
.build();
}
private AgendaSectionResponse.AgendaSectionItem convertToAgendaSectionItem(AgendaSection section) {
List<AgendaSectionResponse.AgendaSectionItem.ParticipantOpinion> opinions = null;
if (section.getOpinions() != null) {
opinions = section.getOpinions().stream()
.map(op -> AgendaSectionResponse.AgendaSectionItem.ParticipantOpinion.builder()
.speaker(op.getSpeaker())
.opinion(op.getOpinion())
.build())
.collect(Collectors.toList());
}
List<AgendaSectionResponse.AgendaSectionItem.TodoItem> todos = null;
if (section.getTodos() != null) {
todos = section.getTodos().stream()
.map(todo -> AgendaSectionResponse.AgendaSectionItem.TodoItem.builder()
.title(todo.getTitle())
.assignee(todo.getAssignee())
.dueDate(todo.getDueDate())
.description(todo.getDescription())
.priority(todo.getPriority())
.build())
.collect(Collectors.toList());
}
return AgendaSectionResponse.AgendaSectionItem.builder()
.id(section.getId())
.minutesId(section.getMinutesId())
.agendaNumber(section.getAgendaNumber())
.agendaTitle(section.getAgendaTitle())
.aiSummaryShort(section.getAiSummaryShort())
.discussions(section.getDiscussions())
.decisions(section.getDecisions())
.pendingItems(section.getPendingItems())
.opinions(opinions)
.todos(todos)
.createdAt(section.getCreatedAt())
.updatedAt(section.getUpdatedAt())
.build();
}
private MeetingStatisticsResponse buildMeetingStatistics(String meetingId) {
Meeting meeting = meetingService.getMeeting(meetingId);
List<AgendaSection> sections = agendaSectionService.getAgendaSectionsByMeeting(meetingId);
// AI가 추출한 Todo 계산
int todoCount = sections.stream()
.mapToInt(s -> s.getTodos() != null ? s.getTodos().size() : 0)
.sum();
// 회의 시간 계산
Integer durationMinutes = null;
if (meeting.getStartedAt() != null && meeting.getEndedAt() != null) {
durationMinutes = (int) java.time.Duration.between(
meeting.getStartedAt(),
meeting.getEndedAt()
).toMinutes();
}
return MeetingStatisticsResponse.builder()
.meetingId(meetingId)
.meetingTitle(meeting.getTitle())
.startTime(meeting.getStartedAt())
.endTime(meeting.getEndedAt())
.durationMinutes(durationMinutes)
.participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0)
.agendaCount(sections.size())
.todoCount(todoCount)
.build();
}
}

View File

@ -0,0 +1,112 @@
package com.unicorn.hgzero.meeting.infra.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 안건별 섹션 조회 응답 DTO
* 회의 종료 화면에서 사용
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "안건별 섹션 조회 응답")
public class AgendaSectionResponse {
@Schema(description = "회의 ID", example = "MTG-2025-001")
private String meetingId;
@Schema(description = "안건별 섹션 목록")
private List<AgendaSectionItem> sections;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "안건별 섹션 항목")
public static class AgendaSectionItem {
@Schema(description = "섹션 ID", example = "AGENDA-SEC-001")
private String id;
@Schema(description = "회의록 ID", example = "MIN-CONSOLIDATED-001")
private String minutesId;
@Schema(description = "안건 번호", example = "1")
private Integer agendaNumber;
@Schema(description = "안건 제목", example = "Q1 마케팅 전략 수립")
private String agendaTitle;
@Schema(description = "AI 생성 짧은 요약", example = "타겟 고객을 20-30대로 설정")
private String aiSummaryShort;
@Schema(description = "논의 사항", example = "신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고...")
private String discussions;
@Schema(description = "결정 사항 목록")
private List<String> decisions;
@Schema(description = "보류 사항 목록")
private List<String> pendingItems;
@Schema(description = "참석자별 의견")
private List<ParticipantOpinion> opinions;
@Schema(description = "AI 추출 Todo 목록")
private List<TodoItem> todos;
@Schema(description = "생성 시간", example = "2025-01-20T16:00:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
@Schema(description = "수정 시간", example = "2025-01-20T16:30:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime updatedAt;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "참석자 의견")
public static class ParticipantOpinion {
@Schema(description = "발언자 이름", example = "김민준")
private String speaker;
@Schema(description = "의견 내용", example = "타겟 고객층을 명확히 설정하여 마케팅 전략 수립 필요")
private String opinion;
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Todo 항목")
public static class TodoItem {
@Schema(description = "Todo 제목", example = "시장 조사 보고서 작성")
private String title;
@Schema(description = "담당자", example = "김민준")
private String assignee;
@Schema(description = "마감일", example = "2025-02-15")
private String dueDate;
@Schema(description = "설명", example = "20-30대 타겟 시장 조사")
private String description;
@Schema(description = "우선순위", example = "HIGH", allowableValues = {"HIGH", "MEDIUM", "LOW"})
private String priority;
}
}
}

View File

@ -0,0 +1,48 @@
package com.unicorn.hgzero.meeting.infra.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 회의 통계 조회 응답 DTO
* 회의 종료 화면에서 통계 정보 표시
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회의 통계 조회 응답")
public class MeetingStatisticsResponse {
@Schema(description = "회의 ID", example = "MTG-2025-001")
private String meetingId;
@Schema(description = "회의 제목", example = "Q1 마케팅 전략 회의")
private String meetingTitle;
@Schema(description = "회의 시작 시간", example = "2025-01-20T14:00:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime startTime;
@Schema(description = "회의 종료 시간", example = "2025-01-20T16:00:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime endTime;
@Schema(description = "회의 총 시간 (분)", example = "120")
private Integer durationMinutes;
@Schema(description = "참석자 수", example = "5")
private Integer participantCount;
@Schema(description = "안건 수", example = "3")
private Integer agendaCount;
@Schema(description = "Todo 수", example = "12")
private Integer todoCount;
}

View File

@ -0,0 +1,89 @@
package com.unicorn.hgzero.meeting.infra.dto.response;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 참석자별 회의록 조회 응답 DTO
* AI Service가 통합 회의록 생성 사용
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "참석자별 회의록 조회 응답")
public class ParticipantMinutesResponse {
@Schema(description = "회의 ID", example = "MTG-2025-001")
private String meetingId;
@Schema(description = "참석자별 회의록 목록")
private List<ParticipantMinutesItem> participantMinutes;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "참석자별 회의록 항목")
public static class ParticipantMinutesItem {
@Schema(description = "회의록 ID", example = "MIN-001")
private String minutesId;
@Schema(description = "작성자 사용자 ID", example = "user@example.com")
private String userId;
@Schema(description = "회의록 제목", example = "Q1 마케팅 전략 회의 - 김민준 작성")
private String title;
@Schema(description = "회의록 상태", example = "FINALIZED", allowableValues = {"DRAFT", "FINALIZED"})
private String status;
@Schema(description = "버전", example = "1")
private Integer version;
@Schema(description = "작성자 ID", example = "user@example.com")
private String createdBy;
@Schema(description = "생성 시간", example = "2025-01-20T14:30:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
@Schema(description = "최종 수정 시간", example = "2025-01-20T15:30:00")
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime lastModifiedAt;
@Schema(description = "회의록 섹션 목록")
private List<MinutesSection> sections;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "회의록 섹션")
public static class MinutesSection {
@Schema(description = "섹션 ID", example = "SEC-001")
private String sectionId;
@Schema(description = "섹션 제목", example = "주요 논의 사항")
private String title;
@Schema(description = "섹션 유형", example = "DISCUSSION", allowableValues = {"DISCUSSION", "DECISION", "ACTION_ITEM", "NOTE"})
private String type;
@Schema(description = "섹션 내용", example = "Q1 마케팅 캠페인 방향성에 대한 논의가 진행되었습니다...")
private String content;
@Schema(description = "섹션 순서", example = "1")
private Integer orderIndex;
}
}
}

View File

@ -0,0 +1,94 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.AgendaSectionWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.AgendaSectionJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 안건별 회의록 섹션 Gateway 구현체
*/
@Component
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class AgendaSectionGateway implements AgendaSectionReader, AgendaSectionWriter {
private final AgendaSectionJpaRepository repository;
@Override
public List<AgendaSection> findByMeetingId(String meetingId) {
log.debug("회의 ID로 안건별 섹션 조회: {}", meetingId);
return repository.findByMeetingIdOrderByAgendaNumberAsc(meetingId).stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public List<AgendaSection> findByMinutesId(String minutesId) {
log.debug("회의록 ID로 안건별 섹션 조회: {}", minutesId);
return repository.findByMinutesIdOrderByAgendaNumberAsc(minutesId).stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<AgendaSection> findById(String id) {
log.debug("ID로 안건별 섹션 조회: {}", id);
return repository.findById(id)
.map(AgendaSectionEntity::toDomain);
}
@Override
public Optional<AgendaSection> findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber) {
log.debug("회의 ID와 안건 번호로 섹션 조회: meetingId={}, agendaNumber={}", meetingId, agendaNumber);
return Optional.ofNullable(repository.findByMeetingIdAndAgendaNumber(meetingId, agendaNumber))
.map(AgendaSectionEntity::toDomain);
}
@Override
@Transactional
public AgendaSection save(AgendaSection section) {
log.debug("안건별 섹션 저장: {}", section.getId());
AgendaSectionEntity entity = AgendaSectionEntity.fromDomain(section);
AgendaSectionEntity saved = repository.save(entity);
return saved.toDomain();
}
@Override
@Transactional
public List<AgendaSection> saveAll(List<AgendaSection> sections) {
log.debug("안건별 섹션 일괄 저장: {} 개", sections.size());
List<AgendaSectionEntity> entities = sections.stream()
.map(AgendaSectionEntity::fromDomain)
.collect(Collectors.toList());
List<AgendaSectionEntity> saved = repository.saveAll(entities);
return saved.stream()
.map(AgendaSectionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
@Transactional
public void delete(String id) {
log.debug("안건별 섹션 삭제: {}", id);
repository.deleteById(id);
}
@Override
@Transactional
public void deleteByMeetingId(String meetingId) {
log.debug("회의 ID로 안건별 섹션 전체 삭제: {}", meetingId);
repository.deleteByMeetingId(meetingId);
}
}

View File

@ -64,6 +64,21 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
.collect(Collectors.toList());
}
@Override
public List<Minutes> findParticipantMinutesByMeetingId(String meetingId) {
log.debug("회의 ID로 참석자별 회의록 조회: {}", meetingId);
return minutesJpaRepository.findByMeetingIdAndUserIdIsNotNull(meetingId).stream()
.map(MinutesEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<Minutes> findConsolidatedMinutesByMeetingId(String meetingId) {
log.debug("회의 ID로 AI 통합 회의록 조회: {}", meetingId);
return minutesJpaRepository.findByMeetingIdAndUserIdIsNull(meetingId)
.map(MinutesEntity::toDomain);
}
@Override
public Minutes save(Minutes minutes) {
// 기존 엔티티 조회 (update) 또는 새로 생성 (insert)

View File

@ -0,0 +1,132 @@
package com.unicorn.hgzero.meeting.infra.gateway.entity;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.meeting.biz.domain.AgendaSection;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.util.ArrayList;
import java.util.List;
/**
* 안건별 회의록 섹션 Entity
* PostgreSQL JSON 컬럼을 사용하여 구조화된 데이터 저장
*/
@Entity
@Table(name = "agenda_sections")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AgendaSectionEntity extends BaseTimeEntity {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Id
@Column(name = "id", 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 = "json")
private String decisionsJson;
@Column(name = "pending_items", columnDefinition = "json")
private String pendingItemsJson;
@Column(name = "opinions", columnDefinition = "json")
private String opinionsJson;
@Column(name = "todos", columnDefinition = "json")
private String todosJson;
/**
* Domain 객체로 변환
*/
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(parseJsonToList(this.decisionsJson, new TypeReference<List<String>>() {}))
.pendingItems(parseJsonToList(this.pendingItemsJson, new TypeReference<List<String>>() {}))
.opinions(parseJsonToList(this.opinionsJson, new TypeReference<List<AgendaSection.ParticipantOpinion>>() {}))
.todos(parseJsonToList(this.todosJson, new TypeReference<List<AgendaSection.TodoItem>>() {}))
.createdAt(this.getCreatedAt())
.updatedAt(this.getUpdatedAt())
.build();
}
/**
* Domain 객체에서 Entity 생성
*/
public static AgendaSectionEntity fromDomain(AgendaSection section) {
return AgendaSectionEntity.builder()
.id(section.getId())
.minutesId(section.getMinutesId())
.meetingId(section.getMeetingId())
.agendaNumber(section.getAgendaNumber())
.agendaTitle(section.getAgendaTitle())
.aiSummaryShort(section.getAiSummaryShort())
.discussions(section.getDiscussions())
.decisionsJson(toJson(section.getDecisions()))
.pendingItemsJson(toJson(section.getPendingItems()))
.opinionsJson(toJson(section.getOpinions()))
.todosJson(toJson(section.getTodos()))
.build();
}
/**
* 객체를 JSON 문자열로 변환
*/
private static String toJson(Object obj) {
if (obj == null) {
return null;
}
try {
return objectMapper.writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 변환 실패", e);
}
}
/**
* JSON 문자열을 리스트로 변환
*/
private static <T> List<T> parseJsonToList(String json, TypeReference<List<T>> typeReference) {
if (json == null || json.trim().isEmpty()) {
return new ArrayList<>();
}
try {
return objectMapper.readValue(json, typeReference);
} catch (JsonProcessingException e) {
throw new RuntimeException("JSON 파싱 실패", e);
}
}
}

View File

@ -31,6 +31,9 @@ public class MinutesEntity extends BaseTimeEntity {
@Column(name = "meeting_id", length = 50, nullable = false)
private String meetingId;
@Column(name = "user_id", length = 100)
private String userId;
@Column(name = "title", length = 200, nullable = false)
private String title;
@ -59,6 +62,7 @@ public class MinutesEntity extends BaseTimeEntity {
return Minutes.builder()
.minutesId(this.minutesId)
.meetingId(this.meetingId)
.userId(this.userId)
.title(this.title)
.sections(this.sections.stream()
.map(MinutesSectionEntity::toDomain)
@ -77,6 +81,7 @@ public class MinutesEntity extends BaseTimeEntity {
return MinutesEntity.builder()
.minutesId(minutes.getMinutesId())
.meetingId(minutes.getMeetingId())
.userId(minutes.getUserId())
.title(minutes.getTitle())
.sections(minutes.getSections() != null
? minutes.getSections().stream()

View File

@ -0,0 +1,47 @@
package com.unicorn.hgzero.meeting.infra.gateway.repository;
import com.unicorn.hgzero.meeting.infra.gateway.entity.AgendaSectionEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* 안건별 회의록 섹션 JPA Repository
*/
@Repository
public interface AgendaSectionJpaRepository extends JpaRepository<AgendaSectionEntity, String> {
/**
* 회의 ID로 안건별 섹션 조회
* 안건 번호 순으로 정렬
*
* @param meetingId 회의 ID
* @return 안건별 섹션 목록
*/
List<AgendaSectionEntity> findByMeetingIdOrderByAgendaNumberAsc(String meetingId);
/**
* 회의록 ID로 안건별 섹션 조회
*
* @param minutesId 회의록 ID
* @return 안건별 섹션 목록
*/
List<AgendaSectionEntity> findByMinutesIdOrderByAgendaNumberAsc(String minutesId);
/**
* 회의 ID와 안건 번호로 섹션 조회
*
* @param meetingId 회의 ID
* @param agendaNumber 안건 번호
* @return 안건 섹션
*/
AgendaSectionEntity findByMeetingIdAndAgendaNumber(String meetingId, Integer agendaNumber);
/**
* 회의 ID로 안건별 섹션 삭제
*
* @param meetingId 회의 ID
*/
void deleteByMeetingId(String meetingId);
}

View File

@ -42,4 +42,15 @@ public interface MinutesJpaRepository extends JpaRepository<MinutesEntity, Strin
* 회의 ID와 버전으로 회의록 조회
*/
Optional<MinutesEntity> findByMeetingIdAndVersion(String meetingId, Integer version);
/**
* 회의 ID로 참석자별 회의록 조회 (user_id IS NOT NULL)
* AI Service가 통합 회의록 생성 사용
*/
List<MinutesEntity> findByMeetingIdAndUserIdIsNotNull(String meetingId);
/**
* 회의 ID로 AI 통합 회의록 조회 (user_id IS NULL)
*/
Optional<MinutesEntity> findByMeetingIdAndUserIdIsNull(String meetingId);
}

View File

@ -0,0 +1,37 @@
-- ========================================
-- V4: agenda_sections 테이블에 todos 컬럼 추가
-- ========================================
-- 작성일: 2025-10-28
-- 설명: AI가 추출한 Todo를 안건별 섹션에 저장
-- ========================================
-- 1. agenda_sections 테이블에 todos 컬럼 추가
-- ========================================
ALTER TABLE agenda_sections
ADD COLUMN IF NOT EXISTS todos JSON;
-- 코멘트 추가
COMMENT ON COLUMN agenda_sections.todos IS 'AI 추출 Todo 목록 (JSON: [{title, assignee, dueDate, description, priority}])';
-- ========================================
-- 2. 샘플 데이터 구조 (참고용)
-- ========================================
--
-- todos JSON 구조:
-- [
-- {
-- "title": "시장 조사 보고서 작성",
-- "assignee": "김민준",
-- "dueDate": "2025-02-15",
-- "description": "20-30대 타겟 시장 조사",
-- "priority": "HIGH"
-- },
-- {
-- "title": "UI/UX 개선안 초안 작성",
-- "assignee": "이서연",
-- "dueDate": "2025-02-20",
-- "description": "모바일 우선 UI 개선",
-- "priority": "MEDIUM"
-- }
-- ]