Merge branch 'main' of https://github.com/hwanny1128/HGZero into feat/notification-noti_request

This commit is contained in:
djeon 2025-10-27 13:33:39 +09:00
commit 0b901d5e7f
41 changed files with 1393 additions and 392 deletions

View File

@ -628,12 +628,12 @@ graph TD
#### 개요
- **목적**: 실시간 회의 진행 및 AI 기반 회의록 자동 작성
- **관련 유저스토리**: UFR-MEET-030, UFR-STT-010/020, UFR-AI-010, UFR-AI-040, UFR-COLLAB-010, UFR-RAG-010/020
- **관련 유저스토리**: UFR-MEET-030, UFR-STT-010/020, UFR-AI-010, UFR-AI-040, UFR-COLLAB-010, UFR-RAG-010/020, UFR-PART-010/020/030, UFR-HOST-010/020, UFR-TERM-010/020
- **비즈니스 중요도**: 높음 (핵심 화면)
- **접근 경로**: 템플릿선택 → "이 템플릿으로 시작"
- **권한**:
- 회의 시작/종료: 회의 생성자 전용
- 참석자 초대: 모든 참석자
- **접근 경로**: 대시보드 → "참여하기" 버튼 (페이지 전환)
- **권한** (MVP 개선):
- **회의 생성자 전용**: 회의 종료, 녹음 제어 (일시정지/재개/종료)
- **모든 참석자**: 회의 참여, AI 주요 내용 체크, 용어 확인, 관련 회의록 확인, 중도 퇴장
#### 주요 기능
1. 음성 녹음 및 실시간 텍스트 변환 (STT)

View File

@ -1,6 +1,6 @@
# AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.3.0)
# AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.4.0)
- [AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.3.0)](#ai기반-회의록-작성-및-이력-관리-개선-서비스---유저스토리-v230)
- [AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.4.0)](#ai기반-회의록-작성-및-이력-관리-개선-서비스---유저스토리-v240)
- [차별화 전략](#차별화-전략)
- [1. 기본 기능 (Hygiene Factors)](#1-기본-기능-hygiene-factors)
- [2. 핵심 차별화 포인트 (Differentiators)](#2-핵심-차별화-포인트-differentiators)
@ -19,7 +19,6 @@
### 2. 핵심 차별화 포인트 (Differentiators)
- **맥락 기반 용어 설명**: 단순 용어 설명을 넘어, 관련 회의록과 업무이력을 바탕으로 실용적인 정보 제공
- **강화된 Todo 연결**: Action item이 담당자의 Todo와 실시간으로 연결되고, 진행 상황이 회의록에 자동 반영
- **섹션 AI 요약 재생성**: 버튼 클릭으로 작성한 섹션 내용을 AI가 요약 (2-3문장, 2-5초 처리)
- **지능형 회의 진행 지원**: 회의 패턴 분석을 통한 안건 추천, 효율성 분석 및 개선 제안
@ -31,7 +30,6 @@
2. **Meeting** - 회의, 회의록, Todo, 실시간 협업 통합 관리
- 회의 관리: 회의 예약, 시작, 종료
- 회의록 관리: 회의록 생성, 수정, 확정
- Todo 관리: Todo 할당, 진행 상황 추적, 회의록 양방향 연결
- 실시간 협업: WebSocket 기반 실시간 동기화, 버전 관리, 충돌 해결
- 템플릿 관리: 회의록 템플릿 관리
- 통계 생성: 회의 및 Todo 통계
@ -83,16 +81,15 @@
---
### UFR-USER-020: [대시보드] 사용자로서 | 나는, 나의 회의 및 Todo 현황을 파악하기 위해 | 대시보드를 조회하고 싶다.
### UFR-USER-020: [대시보드] 사용자로서 | 나는, 나의 회의 현황을 파악하기 위해 | 대시보드를 조회하고 싶다.
**수행절차:**
1. 로그인 후 대시보드 자동 표시 또는 하단 네비게이션에서 홈 아이콘 클릭
2. 통계 블록 확인 (예정된 회의, 나의 Todo)
2. 통계 블록 확인 (예정된 회의, 작성중 회의록)
3. 최근 회의 목록 확인 (최대 3개)
4. 나의 Todo 목록 확인 (최대 3개)
5. 나의 회의록 목록 확인 (최대 4개)
6. 필요 시 섹션별 "전체 보기" 링크 클릭하여 상세 화면 이동
7. FAB 버튼으로 회의 예약 또는 바로 시작
4. 나의 회의록 목록 확인 (최대 4개)
5. 필요 시 "전체 보기" 링크 클릭하여 회의록 목록 화면 이동
6. FAB 버튼으로 회의 예약 또는 바로 시작
**입력:**
- 없음 (자동 렌더링)
@ -101,18 +98,16 @@
- 헤더: 사용자 이름, 오늘 예정된 회의 개수 또는 안내 메시지
- 통계 블록 (2열 그리드):
- 예정된 회의: 전체 예정 + 진행 중 회의 개수
- 나의 Todo: 현재 사용자에게 할당된 전체 Todo 개수
- 작성중 회의록: 내가 참석한 회의 중 '작성중' 상태인 회의록 개수 (클릭 액션 없음, 정보 표시만)
- 최근 회의 (회의록 미생성 우선, 빠른 일시 순, 최대 3개):
- 회의 카드: 상태 배지, 제목, 생성자 표시(👑), 날짜/시간, 참석자 수, 장소, 액션 버튼
- 진행 중 회의: 좌측 녹색 바 강조, "참여하기" 버튼
- 예정 회의: 생성자인 경우 "수정" 버튼
- 완료 회의: "보기" 버튼
- 나의 Todo (미완료 우선, 마감일 순, 최대 3개):
- Todo 카드: 체크박스, D-day 배지, 우선순위 배지, 제목, 회의록 링크, 마감일, 편집 버튼
- 나의 회의록 (최신순, 최대 4개, 2x2 그리드):
- 회의록 카드: 상태 배지, 생성자 표시(👑), 제목, 날짜/시간, 참석자 수, 검증완료율(작성중인 경우)
- 사이드바 (데스크톱): 로고, 메뉴(회의록, Todo 관리), 사용자 정보, 로그아웃
- 하단 네비게이션 (모바일): 홈(활성), 회의록, Todo
- 사이드바 (데스크톱): 로고, 메뉴(대시보드, 회의록), 사용자 정보, 로그아웃
- 하단 네비게이션 (모바일): 홈(활성), 회의록
- FAB 메뉴:
- 회의예약 버튼: 회의 예약 화면으로 이동
- 바로시작 버튼: 템플릿 선택 화면으로 이동
@ -120,7 +115,6 @@
**예외처리:**
- 로그인 안 된 경우: 로그인 화면으로 리다이렉트
- 최근 회의 없음: 빈 상태 표시
- Todo 없음: "할당된 Todo가 없습니다" 빈 상태 표시
- 회의록 없음: "참여한 회의록이 없습니다" 빈 상태 표시
**관련 유저스토리:**
@ -129,7 +123,6 @@
- UFR-MEET-020: 템플릿선택
- UFR-MEET-046: 회의록목록조회
- UFR-MEET-047: 회의록상세조회
- UFR-TODO-040: Todo관리
---
@ -255,9 +248,13 @@
### UFR-MEET-030: [회의시작] 회의 생성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다.
**회의 진입 경로:**
- **경로 1**: 대시보드(02-대시보드.html) → "바로시작" FAB 버튼 → 템플릿 선택(04-템플릿선택.html) → 회의 진행(05-회의진행.html)
- **경로 2**: 대시보드 → 진행 중 회의 "참여하기" 버튼 → 회의 진행(05-회의진행.html)
**수행절차:**
1. 템플릿 선택 완료 또는 대시보드에서 진행 중 회의 "참여하기" 클릭
2. 회의 진행 화면(05-회의진행.html) 표시
1. 위 경로 중 하나를 통해 회의 진행 화면(05-회의진행.html) 진입
2. 회의 진행 화면 표시
3. 녹음 시작 확인 (자동 또는 수동)
4. 타이머 시작 (회의 진행 시간 표시)
5. 웨이브폼 애니메이션 표시 (녹음 상태 시각화)
@ -302,8 +299,12 @@
### UFR-MEET-040: [회의종료] 회의 생성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 요약 내용을 확인한 후 다음 단계를 선택하고 싶다.
**권한:**
- 회의 생성자만 회의를 종료할 수 있음
- 일반 참석자는 "회의 종료" 버튼이 표시되지 않음
**수행절차:**
1. 회의 진행 화면에서 "회의 종료" 버튼 클릭
1. 회의 진행 화면에서 "회의 종료" 버튼 클릭 (생성자 전용)
2. 종료 확인 모달: "회의를 종료하시겠습니까?" 확인
3. 회의 종료 화면(07-회의종료.html) 표시 (확인 전용, 편집 불가)
4. 통계 확인 (4열 그리드):
@ -450,8 +451,9 @@
- 주요 키워드 (태그)
- 핵심내용 요약
- 결정사항
- Todo 진행상황 (필터: 전체, 지연, 마감 임박, 완료)
- Todo 카드: 체크박스, D-day 배지, 우선순위 배지, 제목, 담당자, 마감일, 편집 버튼
- Todo 진행상황 (단순 조회 전용)
- Todo 카드: 제목, 담당자, 마감일만 표시
- D-day 체크, 우선순위 라벨 제거
- 진행률 표시 (수평 바): 완료 Todo 수 / 전체 Todo 수
- 관련회의록 (관련도 배지: 높음, 중간, 낮음)
- **회의록 탭**:
@ -461,103 +463,102 @@
- AI 한줄 요약 (30자 이내, 읽기 전용)
- AI 상세 요약
- 관련회의록 링크
4. Todo 체크박스 클릭 시 완료/미완료 토글
5. Todo 편집 버튼 클릭 시 편집 모달
6. "수정" 버튼 클릭 시 회의록 수정 화면으로 이동
4. "수정" 버튼 클릭 시 회의록 수정 화면으로 이동
**입력:**
- 탭 클릭: 대시보드 / 회의록
- Todo 필터: 전체, 지연, 마감 임박, 완료
- Todo 체크박스: 완료/미완료 토글
- Todo 편집 버튼: 편집 모달 열기
**출력/결과:**
- 헤더: 회의 제목, 생성자 표시 (👑), "수정" 버튼 (권한에 따라 표시)
- 헤더: 회의 제목, "수정" 버튼 (제목 텍스트 바로 옆에 배치, 권한에 따라 표시)
- 대시보드 탭:
- 통계, 키워드, 핵심내용, 결정사항, Todo 진행상황, 관련회의록
- Todo 카드: 제목, 담당자, 마감일만 표시 (D-day, 우선순위 라벨 없음)
- 회의록 탭:
- 기본 정보, 안건별 상세 내용
- Todo 완료 토글: 확인 모달 → 상태 변경 → "Todo가 완료되었습니다" 토스트
- Todo 편집 모달: 제목, 담당자, 마감일, 우선순위 수정
**예외처리:**
- 회의록 조회 실패: "회의록 조회 중 오류가 발생했습니다" 에러 메시지
- Todo 없음: "등록된 Todo가 없습니다" 빈 상태 표시
- 관련회의록 없음: "관련 회의록이 없습니다" 빈 상태 표시
- Todo 완료 토글 실패: "Todo 상태 변경 중 오류가 발생했습니다" 에러 메시지
- Todo 편집 권한 없음: "담당자 또는 회의 생성자만 편집할 수 있습니다" 안내
**관련 유저스토리:**
- UFR-MEET-046: 회의록목록조회
- UFR-MEET-055: 회의록수정
- UFR-TODO-030: Todo완료처리
- UFR-TODO-040: Todo관리
---
### UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 수정하고 싶다.
### UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않은 안건을 | 수정하고 검증완료 체크를 통해 보호하고 싶다.
**수행절차:**
1. 회의록 상세 조회 화면에서 "수정" 버튼 클릭
2. 회의록 수정 화면(11-회의록수정.html) 표시
3. 편집 가능 항목:
3. 여러 참석자가 동시에 수정 화면 진입 가능 (동시 편집 허용)
4. 편집 가능 항목:
- 회의 제목 (텍스트 입력)
- AI 한줄 요약 (읽기 전용, 편집 불가)
- AI 상세 요약 (텍스트 영역, 편집 가능)
- "재생성" 버튼: AI 상세 요약 재생성 (2-3문장, 2-5초 처리)
- 안건 내용 (텍스트 영역)
- 안건별 입력창 내용 (텍스트 영역, 편집 가능)
- "AI 재생성" 버튼: 입력창 내용 기반으로 한줄 요약만 재생성
- 관련회의록 추가/제거
- "추가" 버튼: 검색 모달 열기
- X 버튼: 관련회의록 제거
- 참석자 관리 (회의 생성자만):
- 참석자 추가/삭제
- 05-회의진행 화면과 동일한 UI
4. 읽기 전용 항목:
5. 읽기 전용 항목:
- 회의 일시 (읽기 전용 배지 표시)
- 회의 장소 (읽기 전용 배지 표시)
5. 안건별 검증 완료 토글:
- 검증 완료 전: 토글 활성화 → 잠금
- 검증 완료 후: 잠금 아이콘 표시
- 회의 생성자: 잠금 해제 → 토글 비활성화 → 편집 가능
6. 자동 저장 (30초마다)
- 저장 상태 표시: "저장 중...", "저장됨"
7. 모든 안건 검증 완료 시 "최종 확정" 버튼 활성화
8. 페이지 나가기 전 확인 (변경사항 있을 때)
6. 안건별 검증완료 체크:
- 검증완료 전: 체크박스 비활성화 상태, 편집 가능
- 검증완료 체크 시: 해당 안건 잠금 (편집 불가)
- 회의 생성자만: 검증완료 체크 해제 가능 (잠금 해제)
7. 저장 로직:
- "저장" 버튼 클릭 시:
- 검증완료된 안건: 저장 스킵
- 미검증 안건: 저장 진행
- 저장 결과 알림:
- "N개 안건이 저장되었습니다"
- "M개 안건은 검증완료 상태로 저장되지 않았습니다"
- 저장 불가 안건 목록 표시
8. 모든 안건 검증 완료 시 "최종 확정" 버튼 활성화
9. 페이지 나가기 전 확인 (변경사항 있을 때)
**입력:**
- 회의 제목: 텍스트 입력
- AI 상세 요약: 텍스트 영역 편집, "재생성" 버튼
- 안건 내용: 텍스트 영역 편집
- 안건별 입력창 내용: 텍스트 영역 편집
- AI 재생성: 버튼 클릭 (입력창 내용 기반 한줄 요약 생성)
- 관련회의록: 추가(검색 모달), 제거(X 버튼)
- 참석자: 추가(검색 모달), 삭제(X 버튼) - 회의 생성자만
- 검증 완료: 토글 스위치
- 잠금 해제: 잠금 아이콘 클릭 (회의 생성자만)
- 검증 완료: 체크박스 (회의 생성자: 체크/해제, 참석자: 체크만)
**출력/결과:**
- 편집 화면: 제목, AI 요약, 안건, 관련회의록, 참석자, 검증 토글
- 자동 저장: "저장 중..." → "저장됨" 상태 표시
- AI 재생성: "재생성 중..." → 새 요약 표시 → "재생성되었습니다" 토스트
- 편집 화면: 제목, AI 한줄 요약, 안건별 입력창, 관련회의록, 참석자, 검증 체크박스
- 저장 결과: "N개 안건 저장됨" 토스트, 저장 불가 안건 목록 표시
- AI 재생성: "재생성 중..." → 한줄 요약만 업데이트 → "재생성되었습니다" 토스트
- 관련회의록 추가: 검색 모달 → 선택 → "추가되었습니다" 토스트
- 참석자 추가: 검색 모달 → 선택 → "{이름}님이 추가되었습니다" 토스트
- 검증 완료: 토글 → 잠금 아이콘 표시
- 잠금 해제: 잠금 아이콘 클릭 → "잠금 해제하시겠습니까?" 확인 → 토글 비활성화
- 검증 완료: 체크 → 잠금 아이콘 표시 (편집 불가)
- 잠금 해제 (회의 생성자만): 체크 해제 → "잠금 해제하시겠습니까?" 확인 → 편집 가능
- 최종 확정: 검증률 100% → "최종 확정" 버튼 활성화
**예외처리:**
- 편집 권한 없음 (확정 완료 후, 생성자 아님): 읽기 전용 모드, "회의 생성자만 수정할 수 있습니다" 안내
- 검증 완료 섹션 편집 시도 (참석자): "검증 완료된 섹션은 수정할 수 없습니다" 안내
- 편집 권한 없음 (확정 완료 후): 읽기 전용 모드, "확정된 회의록은 수정할 수 없습니다" 안내
- 검증완료 안건 편집 시도: "검증완료된 안건은 수정할 수 없습니다. 회의 생성자에게 문의하세요" 안내
- 검증완료 해제 권한 없음: "회의 생성자만 검증완료를 해제할 수 있습니다" 안내
- AI 재생성 실패: "재생성 중 오류가 발생했습니다" 에러 메시지
- 자동 저장 실패: "저장 실패" 상태 표시
- 저장 실패: "저장 중 오류가 발생했습니다" 에러 메시지
- 페이지 나가기: "저장되지 않은 변경사항이 있습니다. 나가시겠습니까?" 확인 모달
- 참석자 관리 권한 없음: "회의 생성자만 참석자를 관리할 수 있습니다" 안내
**동시 편집 정책 (MVP):**
- 실시간 동기화 없음 (다른 참석자가 편집하는 내용 안 보임)
- 안건별 검증완료 체크로 충돌 방지
- 마지막 저장 우선 원칙 (Last Write Wins)
**관련 유저스토리:**
- UFR-MEET-047: 회의록상세조회
- UFR-MEET-050: 최종확정
- UFR-AI-035: 섹션AI요약
- UFR-AI-036: AI한줄요약
- UFR-AI-040: 관련회의록연결
- UFR-COLLAB-010: 회의록수정동기화
- UFR-COLLAB-020: 충돌해결
- UFR-COLLAB-030: 검증완료
# 유저스토리 v2.3.0 - AI, STT, RAG, COLLAB, TODO, NOTIFICATION 서비스
@ -742,19 +743,29 @@
---
### UFR-AI-040: [관련회의록연결] 회의 참석자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
### UFR-AI-040: [관련회의록연결] 회의 참석자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결하고 유사 내용을 요약해주기를 원한다.
**기능 개선 사항 (v2.3.1):**
- **최대 개수**: 5개 → 3개로 축소 (MVP)
- **관련도 표시 방식**: 배지(높음/중간/낮음) → 퍼센트(95%, 78%)로 변경
- **유사 내용 요약 추가** (신규):
- AI가 추천한 각 회의록에서 현재 회의와 유사한 부분 자동 추출
- 유사한 내용을 3-5개 문장으로 요약하여 표시
- 전체 회의록을 열지 않아도 핵심 내용 파악 가능
- "전체 회의록 보기" 버튼으로 상세 내용 확인
- **성능 최적화**:
- 과거 회의록 저장 시 요약본 미리 생성 (배치 처리)
- 실시간 요약은 캐싱된 데이터 활용
- 성능 목표: 1초 이내 표시
**수행절차:**
1. 회의 종료 시 또는 회의록 작성 중 AI가 현재 회의 내용 분석
2. 벡터 유사도 검색을 통해 관련 회의록 탐색
- 같은 폴더 내 회의록 우선
- 키워드, 참석자, 안건 유사도 분석
3. 관련도가 높은 회의록 자동 연결 (최대 5개)
4. 관련도 배지 부여:
- 높음 (80% 이상)
- 중간 (60-80%)
- 낮음 (40-60%)
5. 회의 종료 화면, 회의록 수정 화면, 회의록 상세 조회 화면에 표시
3. 관련도가 높은 회의록 자동 연결 (최대 3개로 축소)
4. 각 회의록에서 유사한 내용 추출 및 요약 (신규 추가)
5. 회의 진행 화면, 회의 종료 화면, 회의록 수정 화면, 회의록 상세 조회 화면에 표시
6. 사용자가 수동으로 추가/제거 가능
**입력:**
@ -762,10 +773,12 @@
- 과거 회의록 데이터 (벡터 DB)
**출력/결과:**
- 관련 회의록 목록 (최대 5개):
- 관련 회의록 목록 (최대 3개):
- 회의록 제목
- 날짜
- 관련도 배지 (높음/중간/낮음)
- 관련도 점수 (퍼센트, 예: 95%, 78%)
- **유사 내용 요약 (3-5개 문장)** ← 신규
- "전체 회의록 보기" 버튼
- 회의록 링크
- 회의록 수정 화면: "추가" 버튼, "X" 제거 버튼
- 회의록 상세 조회 화면: 관련회의록 섹션 표시
@ -773,6 +786,7 @@
**예외처리:**
- 관련 회의록 없음: 빈 목록 표시
- 벡터 검색 실패: 최근 회의록 기준 정렬로 대체
- 유사 내용 요약 실패: 회의록 제목과 날짜만 표시
- 네트워크 오류: 로컬 캐시 사용 또는 빈 목록
**관련 유저스토리:**
@ -1070,149 +1084,6 @@
---
## TODO 서비스 (Meeting 서비스에 통합)
### UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다.
**수행절차:**
1. AI 서비스에서 Todo 추출 완료 (UFR-AI-020)
2. Todo 데이터 수신:
- 제목, 담당자 ID, 마감일, 우선순위
- 회의 ID, 안건 ID
3. Todo 데이터베이스에 저장
4. 담당자에게 알림 대상 생성:
- 알림 유형: "Todo 할당"
- 알림 내용: "{회의 제목}에서 Todo가 할당되었습니다: {Todo 제목}"
- 수신자: 담당자
- 발송 예정 시각: 즉시
5. Notification 서비스가 주기적으로 폴링하여 이메일 발송
6. 회의록과 Todo 양방향 연결:
- 회의록 → Todo 링크
- Todo → 회의록 링크
7. 담당자의 Todo 목록 및 대시보드에 표시
**입력:**
- AI가 추출한 Todo 데이터:
- 제목, 담당자, 마감일, 우선순위
- 회의 정보: 회의 ID, 안건 ID, 회의 제목
**출력/결과:**
- Todo 저장 완료
- 알림 대상 생성 (Notification 테이블에 레코드 추가)
- 회의록과 Todo 양방향 링크 생성
- 담당자 Todo 목록 업데이트
- 회의록 화면에 Todo 표시
**예외처리:**
- 담당자 정보 없음: 회의록 생성자로 할당
- Todo 저장 실패: "Todo 저장 중 오류가 발생했습니다" 로그 기록, 재시도
- 알림 대상 생성 실패: 로그 기록, Todo는 정상 저장
**관련 유저스토리:**
- UFR-AI-020: Todo자동추출
- UFR-TODO-040: Todo관리
- UFR-NOTI-010: 알림발송 (신규)
---
### UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
**수행절차:**
1. Todo 관리 화면 또는 회의록 상세 조회 화면에서 Todo 체크박스 클릭
2. 완료 확인 모달: "완료 처리하시겠습니까?"
3. 승인 시 Todo 상태 "완료"로 변경
4. 완료 시각 기록
5. 회의록에 자동 반영:
- 회의록 상세 조회 시 완료된 Todo 표시
- Todo 진행상황 진행률 업데이트
6. 알림 대상 생성 (옵션):
- 회의 생성자에게 Todo 완료 알림
- 알림 내용: "{담당자}님이 Todo를 완료했습니다: {Todo 제목}"
7. "Todo가 완료되었습니다" 토스트 메시지
**미완료로 되돌리기:**
1. 완료된 Todo 체크박스 클릭
2. 확인 모달: "미완료로 변경하시겠습니까?"
3. 승인 시 Todo 상태 "미완료"로 변경
4. 회의록 진행률 자동 업데이트
**입력:**
- Todo 체크박스 클릭 (완료/미완료 토글)
- 확인 모달 승인
**출력/결과:**
- Todo 상태 변경: 완료 ↔ 미완료
- 완료 시각 기록
- 회의록 진행률 업데이트
- 알림 대상 생성 (옵션)
- 토스트 메시지: "Todo가 완료되었습니다" / "미완료로 변경되었습니다"
**예외처리:**
- 네트워크 오류: "Todo 상태 변경 중 오류가 발생했습니다" 에러 메시지
- 확인 모달 취소: 체크박스 상태 원복
**관련 유저스토리:**
- UFR-TODO-010: Todo할당
- UFR-TODO-040: Todo관리
- UFR-MEET-047: 회의록상세조회
- UFR-NOTI-010: 알림발송 (신규)
---
### UFR-TODO-040: [Todo관리] Todo 담당자로서 | 나는, 나의 Todo를 효율적으로 관리하기 위해 | Todo 목록을 조회하고 상태를 변경하고 편집하고 싶다.
**수행절차:**
1. 사이드바 또는 하단 네비게이션에서 "Todo 관리" 메뉴 클릭
2. Todo 관리 화면(09-Todo관리.html) 표시
3. 통계 블록 확인 (3열 그리드):
- 전체 (미완료): 완료되지 않은 전체 Todo 개수
- 마감 임박 (3일 이내): D-3 이내 미완료 Todo
- 지연 (기한 경과): 마감일이 지난 미완료 Todo
4. 필터 탭 선택:
- 전체: 모든 Todo (미완료 우선, 마감일 순)
- 지연: 마감일 지난 Todo
- 마감 임박: D-3 이내 Todo
- 완료: 완료된 Todo
5. Todo 카드 확인:
- 체크박스, D-day 배지, 우선순위 배지
- 제목, 회의록 링크, 마감일
- 편집 버튼 (미완료 Todo만)
6. 체크박스 클릭 시 완료/미완료 토글 (확인 모달)
7. 편집 버튼 클릭 시 편집 모달:
- 제목, 담당자, 마감일, 우선순위 수정
- 권한: 담당자 본인 OR 회의 생성자
8. Todo 카드 클릭 시 해당 회의록 상세 조회 화면으로 이동
**입력:**
- 필터 탭 클릭
- Todo 체크박스 클릭
- 편집 버튼 클릭
- Todo 카드 클릭
**출력/결과:**
- 통계 블록 (3열):
- 전체 (미완료), 마감 임박, 지연 개수
- 필터 탭: 전체, 지연, 마감 임박, 완료 (각 탭에 개수 표시)
- Todo 카드:
- D-day 배지 (완료, D+n(지연), D-DAY, D-n)
- 우선순위 배지 (높음, 보통, 낮음)
- 제목, 회의록 링크, 마감일, 편집 버튼
- 편집 모달: 제목, 담당자, 마감일, 우선순위 입력 필드
- 빈 상태: "할당된 Todo가 없습니다"
**예외처리:**
- Todo 없음: 빈 상태 표시
- 편집 권한 없음: "담당자 또는 회의 생성자만 편집할 수 있습니다" 안내
- 편집 실패: "Todo 수정 중 오류가 발생했습니다" 에러 메시지
- 네트워크 오류: "Todo 조회 중 오류가 발생했습니다" 에러 메시지
**관련 유저스토리:**
- UFR-TODO-010: Todo할당
- UFR-TODO-030: Todo완료처리
- UFR-USER-020: 대시보드 조회
---
## NOTIFICATION 서비스 (신규)
### UFR-NOTI-010: [알림발송] Notification 시스템으로서 | 나는, 사용자에게 중요한 이벤트를 알리기 위해 | 주기적으로 알림 대상을 확인하여 이메일을 발송하고 싶다.
@ -1233,8 +1104,6 @@
- 발송 실패 시 재시도 횟수 증가, 최대 3회 재시도
4. **알림 유형 및 트리거**:
- **Todo 할당**: AI가 Todo 추출 후 담당자에게 알림
- **Todo 완료**: 담당자가 Todo 완료 시 회의 생성자에게 알림 (옵션)
- **회의 시작 알림**: 회의 시작 10분 전 참석자에게 알림
- **회의록 확정**: 회의록 최종 확정 시 모든 참석자에게 알림
- **참석자 초대**: 회의 중 참석자 초대 시 신규 참석자에게 알림
@ -1261,10 +1130,6 @@
**알림 유형별 템플릿 예시:**
**Todo 할당**
- 제목: "[회의록] Todo가 할당되었습니다"
- 내용: "{담당자}님, {회의 제목} 회의에서 다음 Todo가 할당되었습니다:\n- {Todo 제목}\n- 마감일: {마감일}\n\n회의록 보기: {회의록 링크}"
**회의 시작 알림**
- 제목: "[회의록] 10분 후 회의가 시작됩니다"
- 내용: "{참석자}님, {회의 제목} 회의가 10분 후 시작됩니다.\n- 일시: {날짜} {시간}\n- 장소: {장소}\n\n회의 참여하기: {회의 링크}"
@ -1281,9 +1146,256 @@
---
# MVP 개선 사항 (v2.3.1)
## 회의 참석자 권한 및 기능 단순화
### UFR-PART-010: [회의입장] 회의 참석자로서 | 나는, 예정된 회의에 참여하기 위해 | 대시보드에서 "참여하기" 버튼으로 회의에 입장하고 싶다.
**수행절차:**
1. 대시보드에서 진행 중 또는 예정된 회의 확인
2. "참여하기" 버튼 클릭
3. 회의진행 화면으로 페이지 전환 (같은 탭)
4. 회의 참석자로 입장 완료
**입력:**
- 회의 ID
- 사용자 ID
**출력/결과:**
- 회의진행 화면 표시
- 참석자 목록에 추가
**예외처리:**
- 회의 종료됨: "이미 종료된 회의입니다" 안내
- 권한 없음: "참석 권한이 없습니다" 안내
**관련 유저스토리:**
- UFR-MEET-030: 회의시작
---
### UFR-PART-020: [AI기반메모작성] 회의 참석자로서 | 나는, 중요한 내용을 메모로 기록하기 위해 | AI가 추천한 주요 내용을 메모 입력창에 추가하고 편집하고 싶다.
**수행절차:**
1. 회의 진행 중 AI가 실시간으로 주요 내용 감지 및 분석
2. "AI 제안" 탭 하단에 "AI가 감지한 주요 내용" 리스트로 표시
- 각 항목: "[시간] 주요 내용 텍스트" 형식
- 예: "[15:32] 예산 책정 관련 결정", "[15:35] 다음 회의 일정 합의"
3. 참석자가 리스트 항목 선택 시:
- 메모 입력창에 시간과 함께 자동 입력
- 입력된 메모는 수정 가능 (자동/수동 구분 표시)
4. "저장" 버튼 클릭 시 개인 메모로 저장
- 각 참석자별로 개별 저장
- 다른 참석자의 메모는 볼 수 없음
5. 회의 종료 시 AI가 회의록 생성할 때 모든 참석자의 메모 참조
6. 메모는 회의 종료 전까지만 표시 및 편집 가능
**입력:**
- 회의 ID
- AI가 실시간 감지한 주요 내용
- 참석자가 선택하거나 직접 입력한 메모 텍스트
**출력/결과:**
- 메모 입력창: 시간 포함 메모 텍스트
- AI 추천 리스트: 실시간 업데이트되는 주요 내용 항목들
- 저장된 개인 메모: 참석자별 개별 저장
- AI 회의록 생성 시: 모든 참석자의 메모 참조하여 요약 생성
**예외처리:**
- AI 감지 실패: 빈 리스트 표시, 수동 메모 작성은 가능
- 저장 실패: "메모 저장 중 오류가 발생했습니다" 에러 메시지, 로컬 임시 저장
- 회의 종료 후: 메모 조회/편집 불가
**관련 유저스토리:**
- UFR-AI-030: 실시간 AI 제안
- UFR-AI-010: 회의록자동작성 (메모 참조)
- UFR-MEET-040: 회의종료
---
### UFR-PART-030: [회의중도퇴장] 회의 참석자로서 | 나는, 회의를 중간에 나가야 할 때 | "나가기" 버튼으로 회의에서 퇴장하고 싶다.
**수행절차:**
1. 회의진행 화면 상단 "나가기" 버튼 클릭
2. 확인 모달 표시: "회의에서 나가시겠습니까? 회의는 계속 진행됩니다"
3. 확인 클릭 시 퇴장 이벤트 서버 전송
4. 대시보드로 페이지 전환
**입력:**
- 회의 ID
- 사용자 ID
**출력/결과:**
- 대시보드 화면으로 복귀
- 회의록은 종료 시 공유됨
**예외처리:**
- 네트워크 오류: 로컬 처리 후 재시도
**관련 유저스토리:**
- UFR-MEET-030: 회의시작
---
## 회의 생성자 전용 기능
### UFR-HOST-010: [회의종료권한] 회의 생성자로서 | 나는, 회의를 마무리하기 위해 | "회의 종료" 버튼으로만 회의를 종료하고 싶다.
**수행절차:**
1. 회의진행 화면에서 "회의 종료" 버튼은 생성자에게만 표시
2. 일반 참석자에게는 버튼 숨김 처리
3. 생성자가 "회의 종료" 클릭 시 회의 종료 프로세스 진행
**입력:**
- 회의 ID
- 사용자 ID (생성자 여부 확인)
**출력/결과:**
- 생성자: 회의 종료 버튼 표시 및 동작
- 일반 참석자: 버튼 숨김
**예외처리:**
- 권한 없음: 버튼 미표시
**관련 유저스토리:**
- UFR-MEET-040: 회의종료
---
### UFR-HOST-020: [녹음제어권한] 회의 생성자로서 | 나는, 녹음을 관리하기 위해 | 녹음 일시정지/재개/종료 권한을 가지고 싶다.
**수행절차:**
1. 회의진행 화면에서 녹음 제어 버튼은 생성자에게만 표시
2. 일반 참석자에게는 버튼 숨김 처리
**입력:**
- 회의 ID
- 사용자 ID (생성자 여부 확인)
**출력/결과:**
- 생성자: 녹음 제어 버튼 표시 및 동작
- 일반 참석자: 버튼 숨김
**예외처리:**
- 권한 없음: 버튼 미표시
**관련 유저스토리:**
- UFR-STT-010: 음성녹음인식
---
## 용어 설명 기능 (MVP 단순화)
### UFR-TERM-010: [용어자동감지] 회의 참석자로서 | 나는, 전문용어를 이해하기 위해 | AI가 자동으로 감지한 용어를 "용어" 탭에서 확인하고 싶다.
**수행절차:**
1. AI가 STT 분석 중 중요 용어 자동 감지
2. "용어" 탭에 실시간으로 표시
3. 용어 항목 클릭 시 상세 설명 모달 표시
**입력:**
- STT 텍스트
- 회사 용어 사전 (JSON)
**출력/결과:**
- 용어 목록 표시
- 회사 용어는 ⭐ 배지 표시
**예외처리:**
- 용어 감지 실패: 빈 목록 표시
**관련 유저스토리:**
- UFR-RAG-010: 전문용어감지 (기존 기능 유지)
---
### UFR-TERM-020: [회사용어사전] 회의 참석자로서 | 나는, 회사 특화 용어를 정확히 이해하기 위해 | 회사 용어 사전에 등록된 용어를 우선 표시받고 싶다.
**수행절차:**
1. 용어 감지 시 회사 용어 사전(JSON) 먼저 확인
2. 사전에 있으면 회사 특화 설명 + ⭐ 표시
3. 사전에 없으면 AI가 일반 설명 + 회의 맥락 제공
**입력:**
- 용어
- 회사 용어 사전 (terms-dictionary.json)
- 회의 맥락
**출력/결과:**
- 회사 용어: 정의 + 맥락 + ⭐
- 일반 용어: AI 설명
**예외처리:**
- 사전 로드 실패: AI 설명으로 대체
**관련 유저스토리:**
- UFR-RAG-020: 맥락기반용어설명 (기존 기능 유지)
---
### UFR-TERM-030: [용어관리] 관리자로서 | 나는, 회사 특화 용어를 관리하기 위해 | 용어 사전을 등록/수정하고 싶다.
**수행절차:**
1. 관리자 페이지에서 용어 사전 관리
2. JSON 파일 직접 편집 또는 관리 UI 사용
3. 용어 추가/수정/삭제
**입력:**
- 용어명
- 정의
- 맥락
- 카테고리
**출력/결과:**
- terms-dictionary.json 업데이트
**예외처리:**
- 권한 없음: "관리자 권한이 필요합니다" 안내
---
## 기존 유저스토리 수정
### UFR-MEET-040 수정 (회의종료권한)
**기존**: "회의 생성자 또는 참석자가 회의 종료 버튼을 눌러 회의를 종료한다"
**변경**: "회의 생성자만 '회의 종료' 버튼으로 회의를 종료할 수 있다"
**적용 위치**: [UFR-MEET-040: 회의종료](#ufr-meet-040-회의종료)
---
### UFR-MEET-030 개선 (회의 진입 경로)
**변경**: 대시보드에서 회의 진행 화면 진입 경로 명확화
- **경로 1**: 대시보드 → "바로시작" FAB 버튼 → 템플릿 선택 → 회의 진행
- **경로 2**: 대시보드 → 진행 중 회의 "참여하기" 버튼 → 회의 진행
**적용 위치**: [UFR-MEET-030: 회의시작](#ufr-meet-030-회의시작)
---
### UFR-AI-040 개선 (관련회의록연결)
**변경 및 개선 사항**:
1. **최대 개수**: 5개 → 3개로 축소 (MVP)
2. **관련도 표시 방식**: 배지(높음/중간/낮음) → 퍼센트(95%, 78%)로 변경
3. **유사 내용 요약 추가** (신규):
- AI가 추천한 각 회의록에서 현재 회의와 유사한 부분 자동 추출
- 유사한 내용을 3-5개 문장으로 요약하여 표시
- 전체 회의록을 열지 않아도 핵심 내용 파악 가능
- "전체 회의록 보기" 버튼으로 상세 내용 확인
4. **성능 최적화**:
- 과거 회의록 저장 시 요약본 미리 생성 (배치 처리)
- 실시간 요약은 캐싱된 데이터 활용
- 성능 목표: 1초 이내 표시
**적용 위치**: [UFR-AI-040: 관련회의록연결](#ufr-ai-040-관련회의록연결)
---
## 문서 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|-----------|
| v2.4.0 | 2025-10-27 | 팀 전체 | • MVP 스코프 축소: Todo 관리 기능 제거<br>• UFR-USER-020 수정: 대시보드에서 "나의 Todo" 제거, "작성중 회의록" 추가<br>• UFR-PART-020 변경: AI주요내용체크 → AI기반메모작성 (메모 입력창 + AI 추천)<br>• UFR-AI-010 개선: 회의록 생성 시 참석자 메모 참조<br>• UFR-MEET-055 개선: 회의록 수정 시 실시간 협업 제거, 검증완료 체크로 보호<br>• TODO 서비스 전체 제거 (UFR-TODO-010/030/040)<br>• NOTIFICATION 서비스: Todo 관련 알림 제거<br>• 네비게이션 간소화: Todo 관리 메뉴 제거 (대시보드, 회의록만 유지) |
| v2.3.1 | 2025-10-27 | 팀 전체 | • MVP 개선: 회의 참석자 권한 단순화<br>• 신규 유저스토리: UFR-PART-010/020/030 (참석자 공통), UFR-HOST-010/020 (생성자 전용)<br>• 신규 유저스토리: UFR-TERM-010/020/030 (용어 기능 MVP 단순화)<br>• UFR-MEET-040 수정: 회의 종료 권한 생성자 전용으로 명확화<br>• UFR-MEET-030 개선: 회의 진입 경로 2가지 명시 (바로시작, 참여하기)<br>• UFR-AI-040 개선: 관련 회의록 유사 내용 요약 추가 (최대 3개, 퍼센트 표시, 3-5문장 요약)<br>• 메모 기능 단순화: 체크박스 방식으로 변경<br>• 용어 설명 단순화: JSON 용어 사전 방식 도입 |
| v2.3.0 | 2025-10-24 | 이미준 | • 프로토타입 분석을 통한 유저스토리 전면 재정비<br>• 신규 유저스토리 추가: UFR-MEET-015 (참석자 실시간 초대), UFR-NOTI-010 (알림 발송)<br>• 알림 아키텍처 폴링 방식으로 통일 (실시간 발송 → 주기적 폴링)<br>• 10개 프로토타입 화면 반영 완료<br>• 마이크로서비스 구성 재정의 (User, Meeting, STT, AI, Notification)<br>• 기존 24개 유저스토리 ID 승계 및 정리 |
| v2.2.0 | 2025-10-23 | 이미준 | 이전 버전 |

25
meeting/README.md Normal file
View File

@ -0,0 +1,25 @@
# notification 메일 알림발송
아래와 같이 메일발송 Email List를 loop돌려서 event 객체를 생성한 후에 `publishNotificationRequest` 메소드를 통해 Event 메세지 발행하시면 됩니다.
```
// 각 참석자에게 개별 알림 이벤트 발행
for (String participantEmail : participants) {
NotificationRequestEvent event = NotificationRequestEvent.builder()
.notificationType("MEETING_INVITATION")
.recipientEmail(participantEmail)
.recipientId(participantEmail)
.recipientName(participantEmail)
.title("회의 초대")
.message(String.format("'%s' 회의에 초대되었습니다. 일시: %s, 장소: %s",
title, startTime, location))
.relatedEntityId(meetingId)
.relatedEntityType("MEETING")
.requestedBy(organizerId)
.requestedByName(organizerName)
.eventTime(LocalDateTime.now())
.build();
publishNotificationRequest(event);
}
```

View File

@ -119,7 +119,20 @@ public class Meeting {
public void addParticipant(String participantEmail) {
if (this.participants == null) {
this.participants = new ArrayList<>();
} else if (!(this.participants instanceof ArrayList)) {
// 불변 리스트인 경우 새로운 ArrayList로 변환
this.participants = new ArrayList<>(this.participants);
}
this.participants.add(participantEmail);
if (!this.participants.contains(participantEmail)) {
this.participants.add(participantEmail);
}
}
/**
* 템플릿 적용
*/
public void applyTemplate(String templateId) {
this.templateId = templateId;
}
}

View File

@ -0,0 +1,122 @@
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 MeetingAnalysis {
/**
* 분석 ID
*/
private String analysisId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 회의록 ID
*/
private String minutesId;
/**
* 주요 키워드
*/
private List<String> keywords;
/**
* 안건별 분석 결과
*/
private List<AgendaAnalysis> agendaAnalyses;
/**
* 전체 회의 분석 상태 (PENDING, IN_PROGRESS, COMPLETED, FAILED)
*/
private String status;
/**
* 분석 완료 시간
*/
private LocalDateTime completedAt;
/**
* 생성 시간
*/
private LocalDateTime createdAt;
/**
* 분석 완료 처리
*/
public void complete() {
this.status = "COMPLETED";
this.completedAt = LocalDateTime.now();
}
/**
* 분석 실패 처리
*/
public void fail() {
this.status = "FAILED";
}
/**
* 분석 진행 처리
*/
public void startAnalysis() {
this.status = "IN_PROGRESS";
}
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class AgendaAnalysis {
/**
* 안건 ID
*/
private String agendaId;
/**
* 안건 제목
*/
private String title;
/**
* AI 요약 (간략)
*/
private String aiSummaryShort;
/**
* 논의 주제
*/
private String discussion;
/**
* 결정 사항
*/
private List<String> decisions;
/**
* 보류 사항
*/
private List<String> pending;
/**
* 추출된 Todo 목록
*/
private List<String> extractedTodos;
}
}

View File

@ -0,0 +1,45 @@
package com.unicorn.hgzero.meeting.biz.dto;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
/**
* 회의 종료 비즈니스 DTO
*/
@Getter
@Builder
public class MeetingEndDTO {
private final String title;
private final int participantCount;
private final int durationMinutes;
private final int agendaCount;
private final int todoCount;
private final List<String> keywords;
private final List<AgendaSummaryDTO> agendaSummaries;
@Getter
@Builder
public static class AgendaSummaryDTO {
private final String title;
private final String aiSummaryShort;
private final AgendaDetailsDTO details;
private final List<TodoSummaryDTO> todos;
}
@Getter
@Builder
public static class AgendaDetailsDTO {
private final String discussion;
private final List<String> decisions;
private final List<String> pending;
}
@Getter
@Builder
public static class TodoSummaryDTO {
private final String title;
}
}

View File

@ -0,0 +1,44 @@
package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.ApplyTemplateUseCase;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 회의록 템플릿 적용 서비스
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class ApplyTemplateService implements ApplyTemplateUseCase {
private final MeetingReader meetingReader;
private final MeetingWriter meetingWriter;
@Override
public Meeting applyTemplate(ApplyTemplateCommand command) {
log.debug("템플릿 적용 시작 - meetingId: {}, templateId: {}",
command.meetingId(), command.templateId());
// 회의 조회
Meeting meeting = meetingReader.findById(command.meetingId())
.orElseThrow(() -> new IllegalArgumentException("회의를 찾을 수 없습니다: " + command.meetingId()));
// 템플릿 적용 (회의 도메인에서 처리)
meeting.applyTemplate(command.templateId());
// 회의 정보 업데이트
Meeting updatedMeeting = meetingWriter.save(meeting);
log.debug("템플릿 적용 완료 - meetingId: {}, templateId: {}",
command.meetingId(), command.templateId());
return updatedMeeting;
}
}

View File

@ -3,11 +3,16 @@ package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis;
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
import com.unicorn.hgzero.meeting.biz.domain.Session;
import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO;
import com.unicorn.hgzero.meeting.biz.usecase.in.meeting.*;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingAnalysisReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingAnalysisWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionWriter;
@ -42,7 +47,10 @@ public class MeetingService implements
private final MeetingWriter meetingWriter;
private final SessionReader sessionReader;
private final SessionWriter sessionWriter;
private final MinutesReader minutesReader;
private final MinutesWriter minutesWriter;
private final MeetingAnalysisReader meetingAnalysisReader;
private final MeetingAnalysisWriter meetingAnalysisWriter;
private final CacheService cacheService;
private final EventPublisher eventPublisher;
private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter participantWriter;
@ -263,30 +271,165 @@ public class MeetingService implements
}
/**
* 회의 종료
* 회의 종료 AI 분석
*/
@Override
@Transactional
public Meeting endMeeting(String meetingId) {
public MeetingEndDTO endMeeting(String meetingId) {
log.info("Ending meeting: {}", meetingId);
// 회의 조회
// 1. 회의 조회
log.debug("Searching for meeting with ID: {}", meetingId);
Meeting meeting = meetingReader.findById(meetingId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
.orElseThrow(() -> {
log.error("Meeting not found: {}", meetingId);
return new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의를 찾을 수 없습니다: " + meetingId);
});
log.debug("Found meeting: {}, status: {}", meeting.getTitle(), meeting.getStatus());
// 회의 상태 검증
if (!"IN_PROGRESS".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
// 2. 회의 상태 검증 (SCHEDULED 또는 IN_PROGRESS만 종료 가능)
if (!"SCHEDULED".equals(meeting.getStatus()) && !"IN_PROGRESS".equals(meeting.getStatus())) {
log.warn("Invalid meeting status for ending: meetingId={}, status={}", meetingId, meeting.getStatus());
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE,
"회의를 종료할 수 없는 상태입니다. 현재 상태: " + meeting.getStatus());
}
// 회의 종료
// 3. 회의 종료
meeting.end();
// 저장
Meeting updatedMeeting = meetingWriter.save(meeting);
// 4. 회의록 조회 (SCHEDULED 상태면 회의록 생성, IN_PROGRESS면 기존 회의록 조회)
Minutes minutes;
if ("SCHEDULED".equals(meeting.getStatus())) {
// SCHEDULED 상태에서 종료하는 경우 회의록 생성
String minutesId = UUID.randomUUID().toString();
minutes = Minutes.builder()
.minutesId(minutesId)
.meetingId(meetingId)
.title(meeting.getTitle() + " - 회의록")
.sections(List.of())
.status("DRAFT")
.version(1)
.createdBy(meeting.getOrganizerId())
.createdAt(LocalDateTime.now())
.build();
minutesWriter.save(minutes);
log.info("Empty minutes created for SCHEDULED meeting: meetingId={}, minutesId={}", meetingId, minutesId);
} else {
// IN_PROGRESS 상태면 기존 회의록 조회
log.debug("Searching for existing minutes for meeting: {}", meetingId);
minutes = minutesReader.findLatestByMeetingId(meetingId)
.orElseThrow(() -> {
log.error("Minutes not found for meeting: {}", meetingId);
return new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의록을 찾을 수 없습니다: " + meetingId);
});
log.debug("Found minutes: {}", minutes.getTitle());
}
// 5. AI 분석 수행 (현재는 Mock 데이터로 구현)
MeetingAnalysis analysis = performAIAnalysis(meeting, minutes);
meetingAnalysisWriter.save(analysis);
// 6. 결과 DTO 구성
MeetingEndDTO result = buildMeetingEndDTO(meeting, analysis);
log.info("Meeting ended successfully: {}", meetingId);
return updatedMeeting;
return result;
}
/**
* AI 분석 수행 (Mock 구현)
*/
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()
);
return MeetingAnalysis.builder()
.analysisId(UUID.randomUUID().toString())
.meetingId(meeting.getMeetingId())
.minutesId(minutes.getMinutesId())
.keywords(keywords)
.agendaAnalyses(agendaAnalyses)
.status("COMPLETED")
.completedAt(LocalDateTime.now())
.createdAt(LocalDateTime.now())
.build();
}
/**
* MeetingEndDTO 구성
*/
private MeetingEndDTO buildMeetingEndDTO(Meeting meeting, MeetingAnalysis analysis) {
// 회의 시간 계산 (Mock)
int durationMinutes = 90;
// 전체 Todo 개수 계산
int totalTodos = analysis.getAgendaAnalyses().stream()
.mapToInt(agenda -> agenda.getExtractedTodos().size())
.sum();
// 안건별 요약 DTO 변환
List<MeetingEndDTO.AgendaSummaryDTO> agendaSummaries = analysis.getAgendaAnalyses().stream()
.map(agenda -> MeetingEndDTO.AgendaSummaryDTO.builder()
.title(agenda.getTitle())
.aiSummaryShort(agenda.getAiSummaryShort())
.details(MeetingEndDTO.AgendaDetailsDTO.builder()
.discussion(agenda.getDiscussion())
.decisions(agenda.getDecisions())
.pending(agenda.getPending())
.build())
.todos(agenda.getExtractedTodos().stream()
.map(todo -> MeetingEndDTO.TodoSummaryDTO.builder()
.title(todo)
.build())
.toList())
.build())
.toList();
return MeetingEndDTO.builder()
.title(meeting.getTitle())
.participantCount(meeting.getParticipants() != null ? meeting.getParticipants().size() : 0)
.durationMinutes(durationMinutes)
.agendaCount(analysis.getAgendaAnalyses().size())
.todoCount(totalTodos)
.keywords(analysis.getKeywords())
.agendaSummaries(agendaSummaries)
.build();
}
/**

View File

@ -0,0 +1,25 @@
package com.unicorn.hgzero.meeting.biz.usecase.in.meeting;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
/**
* 회의록 템플릿 적용 UseCase
*/
public interface ApplyTemplateUseCase {
/**
* 회의에 템플릿을 적용
*
* @param command 템플릿 적용 명령
* @return 업데이트된 회의 정보
*/
Meeting applyTemplate(ApplyTemplateCommand command);
/**
* 템플릿 적용 명령
*/
record ApplyTemplateCommand(
String meetingId,
String templateId
) {}
}

View File

@ -1,6 +1,6 @@
package com.unicorn.hgzero.meeting.biz.usecase.in.meeting;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO;
/**
* 회의 종료 UseCase
@ -8,7 +8,7 @@ import com.unicorn.hgzero.meeting.biz.domain.Meeting;
public interface EndMeetingUseCase {
/**
* 회의 종료
* 회의 종료 AI 분석
*/
Meeting endMeeting(String meetingId);
MeetingEndDTO endMeeting(String meetingId);
}

View File

@ -0,0 +1,26 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis;
import java.util.Optional;
/**
* 회의 분석 Reader
*/
public interface MeetingAnalysisReader {
/**
* 회의 ID로 분석 결과 조회
*/
Optional<MeetingAnalysis> findByMeetingId(String meetingId);
/**
* 분석 ID로 분석 결과 조회
*/
Optional<MeetingAnalysis> findById(String analysisId);
/**
* 회의록 ID로 분석 결과 조회
*/
Optional<MeetingAnalysis> findByMinutesId(String minutesId);
}

View File

@ -0,0 +1,19 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis;
/**
* 회의 분석 Writer
*/
public interface MeetingAnalysisWriter {
/**
* 분석 결과 저장
*/
MeetingAnalysis save(MeetingAnalysis analysis);
/**
* 분석 결과 삭제
*/
void delete(String analysisId);
}

View File

@ -1,8 +1,6 @@
package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.meeting.biz.dto.DashboardDTO;
import com.unicorn.hgzero.meeting.biz.usecase.in.dashboard.GetDashboardUseCase;
import com.unicorn.hgzero.meeting.infra.dto.response.DashboardResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -14,6 +12,11 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* 대시보드 REST API Controller
* 사용자별 맞춤 대시보드 데이터 제공
@ -25,17 +28,15 @@ import org.springframework.web.bind.annotation.*;
@Slf4j
public class DashboardController {
private final GetDashboardUseCase getDashboardUseCase;
/**
* 대시보드 데이터 조회
* 대시보드 데이터 조회 ( 데이터)
*
* @param userId 사용자 ID
* @return 대시보드 데이터
*/
@Operation(
summary = "대시보드 데이터 조회",
description = "사용자별 맞춤 대시보드 정보를 조회합니다. 예정된 회의 목록, 진행 중 Todo 목록, 최근 회의록 목록, 통계 정보를 포함합니다.",
description = "사용자별 맞춤 대시보드 정보를 조회합니다. 예정된 회의 목록, 최근 회의록 목록, 통계 정보를 포함합니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@GetMapping
@ -49,12 +50,80 @@ public class DashboardController {
log.info("대시보드 데이터 조회 요청 - userId: {}", userId);
var dashboardData = getDashboardUseCase.getDashboard(userId);
var dashboardDTO = DashboardDTO.from(dashboardData);
var response = DashboardResponse.from(dashboardDTO);
// 데이터 생성
DashboardResponse mockResponse = createMockDashboardData();
log.info("대시보드 데이터 조회 완료 - userId: {}", userId);
return ResponseEntity.ok(ApiResponse.success(response));
return ResponseEntity.ok(ApiResponse.success(mockResponse));
}
/**
* 데이터 생성
*/
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();
}
}

View File

@ -8,6 +8,7 @@ import com.unicorn.hgzero.meeting.infra.dto.request.InviteParticipantRequest;
import com.unicorn.hgzero.meeting.infra.dto.request.SelectTemplateRequest;
import com.unicorn.hgzero.meeting.infra.dto.response.InviteParticipantResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MeetingResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MeetingEndResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.SessionResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@ -20,6 +21,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import java.util.List;
/**
* 회의 관리 REST API Controller
@ -38,6 +40,7 @@ public class MeetingController {
private final GetMeetingUseCase getMeetingUseCase;
private final CancelMeetingUseCase cancelMeetingUseCase;
private final InviteParticipantUseCase inviteParticipantUseCase;
private final ApplyTemplateUseCase applyTemplateUseCase;
/**
* 회의 예약
@ -113,11 +116,17 @@ public class MeetingController {
log.info("템플릿 적용 요청 - meetingId: {}, templateId: {}", meetingId, request.getTemplateId());
var meetingData = getMeetingUseCase.getMeeting(meetingId);
var meetingData = applyTemplateUseCase.applyTemplate(
new ApplyTemplateUseCase.ApplyTemplateCommand(
meetingId,
request.getTemplateId()
)
);
var meetingDTO = MeetingDTO.from(meetingData);
var response = MeetingResponse.from(meetingDTO);
log.info("템플릿 적용 완료 - meetingId: {}", meetingId);
log.info("템플릿 적용 완료 - meetingId: {}, templateId: {}", meetingId, request.getTemplateId());
return ResponseEntity.ok(ApiResponse.success(response));
}
@ -162,15 +171,15 @@ public class MeetingController {
*
* @param meetingId 회의 ID
* @param userId 사용자 ID
* @return 회의 정보
* @return 회의 종료 결과
*/
@Operation(
summary = "회의 종료",
description = "진행 중인 회의를 종료하고 회의록 작성을 완료합니다. 자동 Todo 추출 및 알림 발송이 수행됩니다.",
description = "진행 중인 회의를 종료하고 AI 분석을 통해 회의록을 생성합니다. 주요 키워드 추출, 안건별 요약, Todo 자동 추출이 수행됩니다.",
security = @SecurityRequirement(name = "bearerAuth")
)
@PostMapping("/{meetingId}/end")
public ResponseEntity<ApiResponse<MeetingResponse>> endMeeting(
public ResponseEntity<ApiResponse<MeetingEndResponse>> endMeeting(
@Parameter(description = "회의 ID", required = true)
@PathVariable String meetingId,
@Parameter(description = "사용자 ID", required = true)
@ -182,15 +191,102 @@ public class MeetingController {
log.info("회의 종료 요청 - meetingId: {}, userId: {}", meetingId, userId);
var meetingData = endMeetingUseCase.endMeeting(meetingId);
var meetingDTO = MeetingDTO.from(meetingData);
var response = MeetingResponse.from(meetingDTO);
// Mock 데이터로 응답 (개발용)
var response = createMockMeetingEndResponse(meetingId);
log.info("회의 종료 완료 - meetingId: {}", meetingId);
log.info("회의 종료 완료 (Mock) - meetingId: {}", meetingId);
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 회의 종료 응답 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();
}
/**
* 회의 정보 조회
*

View File

@ -1,13 +1,8 @@
package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.meeting.biz.dto.TemplateDTO;
import com.unicorn.hgzero.meeting.biz.service.TemplateService;
import com.unicorn.hgzero.meeting.infra.dto.response.TemplateListResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.TemplateDetailResponse;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@ -15,12 +10,14 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* 템플릿 관리 API Controller
* 템플릿 목록 조회, 상세 조회 기능
* 템플릿 관리API Controller
* 고정된 템플릿 목록을 제공합니다
*/
@RestController
@RequestMapping("/api/templates")
@ -29,11 +26,8 @@ import java.util.stream.Collectors;
@Tag(name = "Template", description = "템플릿 관리 API")
public class TemplateController {
private final TemplateService templateService;
private final CacheService cacheService;
/**
* 템플릿 목록 조회
* 템플릿 목록 조회 (고정 데이터 반환)
* GET /api/templates
*/
@GetMapping
@ -45,40 +39,19 @@ public class TemplateController {
})
public ResponseEntity<ApiResponse<TemplateListResponse>> getTemplateList(
@RequestHeader("X-User-Id") String userId,
@RequestHeader("X-User-Name") String userName,
@Parameter(description = "템플릿 카테고리") @RequestParam(required = false) String category,
@Parameter(description = "활성 상태 (true: 활성, false: 비활성)") @RequestParam(required = false) Boolean isActive) {
@RequestHeader("X-User-Name") String userName) {
log.info("템플릿 목록 조회 요청 - userId: {}, category: {}, isActive: {}",
userId, category, isActive);
log.info("템플릿 목록 조회 요청 - userId: {}", userId);
try {
// 캐시 확인
String cacheKey = String.format("templates:list:%s:%s",
(category != null ? category : "all"),
(isActive != null ? isActive.toString() : "all"));
TemplateListResponse cachedResponse = cacheService.getCachedTemplateList(cacheKey);
if (cachedResponse != null) {
log.debug("캐시된 템플릿 목록 반환");
return ResponseEntity.ok(ApiResponse.success(cachedResponse));
}
// 템플릿 목록 조회
List<TemplateDTO> templates = templateService.getTemplateList(category, isActive);
// 응답 DTO 생성
List<TemplateListResponse.TemplateItem> templateItems = templates.stream()
.map(this::convertToTemplateItem)
.collect(Collectors.toList());
// 고정된 템플릿 데이터 생성
List<TemplateListResponse.TemplateItem> templateItems = createFixedTemplates();
TemplateListResponse response = TemplateListResponse.builder()
.templateList(templateItems)
.totalCount(templateItems.size())
.build();
// 캐시 저장
cacheService.cacheTemplateList(cacheKey, response);
log.info("템플릿 목록 조회 성공 - count: {}", templateItems.size());
return ResponseEntity.ok(ApiResponse.success(response));
@ -90,99 +63,103 @@ public class TemplateController {
}
/**
* 템플릿 상세 조회
* GET /api/templates/{templateId}
* 고정된 템플릿 데이터 생성
*/
@GetMapping("/{templateId}")
@Operation(summary = "템플릿 상세 조회", description = "템플릿 상세 정보를 조회합니다")
public ResponseEntity<ApiResponse<TemplateDetailResponse>> getTemplateDetail(
@RequestHeader("X-User-Id") String userId,
@RequestHeader("X-User-Name") String userName,
@Parameter(description = "템플릿 ID") @PathVariable String templateId) {
log.info("템플릿 상세 조회 요청 - userId: {}, templateId: {}", userId, templateId);
try {
// 캐시 확인
TemplateDetailResponse cachedResponse = cacheService.getCachedTemplateDetail(templateId);
if (cachedResponse != null) {
log.debug("캐시된 템플릿 상세 반환 - templateId: {}", templateId);
return ResponseEntity.ok(ApiResponse.success(cachedResponse));
}
// 템플릿 조회
TemplateDTO templateDTO = templateService.getTemplateById(templateId);
// 응답 DTO 생성
TemplateDetailResponse response = convertToTemplateDetailResponse(templateDTO);
// 캐시 저장
cacheService.cacheTemplateDetail(templateId, response);
log.info("템플릿 상세 조회 성공 - templateId: {}", templateId);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("템플릿 상세 조회 실패 - templateId: {}", templateId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("템플릿 상세 조회에 실패했습니다"));
}
private List<TemplateListResponse.TemplateItem> createFixedTemplates() {
List<TemplateListResponse.TemplateItem> templates = new ArrayList<>();
// 일반 회의 템플릿
templates.add(TemplateListResponse.TemplateItem.builder()
.templateId("general")
.name("일반 회의")
.description("기본 회의록 형식")
.category("meeting")
.icon("📋")
.isActive(true)
.usageCount(0)
.createdAt(LocalDateTime.now())
.lastUsedAt(null)
.createdBy("system")
.sections(Arrays.asList(
createSectionInfo("회의 개요", "회의 기본 정보", 1, true),
createSectionInfo("논의 사항", "주요 논의 내용", 2, true),
createSectionInfo("결정 사항", "회의에서 결정된 사항", 3, true),
createSectionInfo("액션 아이템", "향후 진행할 작업", 4, true)
))
.build());
// 스크럼 회의 템플릿
templates.add(TemplateListResponse.TemplateItem.builder()
.templateId("scrum")
.name("스크럼 회의")
.description("데일리 스탠드업 형식")
.category("agile")
.icon("🏃")
.isActive(true)
.usageCount(0)
.createdAt(LocalDateTime.now())
.lastUsedAt(null)
.createdBy("system")
.sections(Arrays.asList(
createSectionInfo("어제 한 일", "지난 작업일에 완료한 작업", 1, true),
createSectionInfo("오늘 할 일", "오늘 진행할 예정 작업", 2, true),
createSectionInfo("블로커/이슈", "진행을 방해하는 요소", 3, false)
))
.build());
// 킥오프 회의 템플릿
templates.add(TemplateListResponse.TemplateItem.builder()
.templateId("kickoff")
.name("킥오프 회의")
.description("프로젝트 시작 회의")
.category("project")
.icon("🚀")
.isActive(true)
.usageCount(0)
.createdAt(LocalDateTime.now())
.lastUsedAt(null)
.createdBy("system")
.sections(Arrays.asList(
createSectionInfo("프로젝트 개요", "프로젝트 기본 정보", 1, true),
createSectionInfo("목표 및 범위", "프로젝트 목표와 범위", 2, true),
createSectionInfo("역할 및 책임", "팀원별 역할과 책임", 3, true),
createSectionInfo("일정 및 마일스톤", "프로젝트 일정", 4, true)
))
.build());
// 주간 회의 템플릿
templates.add(TemplateListResponse.TemplateItem.builder()
.templateId("weekly")
.name("주간 회의")
.description("주간 리뷰 및 계획")
.category("review")
.icon("📅")
.isActive(true)
.usageCount(0)
.createdAt(LocalDateTime.now())
.lastUsedAt(null)
.createdBy("system")
.sections(Arrays.asList(
createSectionInfo("지난주 성과", "지난주 달성한 성과", 1, true),
createSectionInfo("이번주 계획", "이번주 진행할 계획", 2, true),
createSectionInfo("주요 이슈", "해결이 필요한 이슈", 3, false),
createSectionInfo("다음 액션", "다음 주 액션 아이템", 4, true)
))
.build());
return templates;
}
// Helper methods
private TemplateListResponse.TemplateItem convertToTemplateItem(TemplateDTO templateDTO) {
// 섹션 정보 변환
List<TemplateListResponse.TemplateSectionInfo> sections = templateDTO.getSections().stream()
.map(section -> TemplateListResponse.TemplateSectionInfo.builder()
.title(section.getTitle())
.description(section.getDescription())
.orderIndex(section.getOrderIndex())
.isRequired(section.isRequired())
.build())
.collect(Collectors.toList());
return TemplateListResponse.TemplateItem.builder()
.templateId(templateDTO.getTemplateId())
.name(templateDTO.getName())
.description(templateDTO.getDescription())
.category(templateDTO.getCategory())
.isActive(templateDTO.isActive())
.usageCount(templateDTO.getUsageCount())
.createdAt(templateDTO.getCreatedAt())
.lastUsedAt(templateDTO.getLastUsedAt())
.createdBy(templateDTO.getCreatedBy())
.sections(sections)
.build();
}
private TemplateDetailResponse convertToTemplateDetailResponse(TemplateDTO templateDTO) {
// 섹션 상세 정보 변환
List<TemplateDetailResponse.SectionDetail> sections = templateDTO.getSections().stream()
.map(section -> TemplateDetailResponse.SectionDetail.builder()
.sectionId(section.getSectionId())
.title(section.getTitle())
.description(section.getDescription())
.content(section.getContent())
.orderIndex(section.getOrderIndex())
.isRequired(section.isRequired())
.inputType(section.getInputType())
.placeholder(section.getPlaceholder())
.maxLength(section.getMaxLength())
.isEditable(section.isEditable())
.build())
.collect(Collectors.toList());
return TemplateDetailResponse.builder()
.templateId(templateDTO.getTemplateId())
.name(templateDTO.getName())
.description(templateDTO.getDescription())
.category(templateDTO.getCategory())
.isActive(templateDTO.isActive())
.usageCount(templateDTO.getUsageCount())
.createdAt(templateDTO.getCreatedAt())
.lastUsedAt(templateDTO.getLastUsedAt())
.createdBy(templateDTO.getCreatedBy())
.sections(sections)
/**
* 템플릿 섹션 정보 생성 헬퍼 메서드
*/
private TemplateListResponse.TemplateSectionInfo createSectionInfo(
String title, String description, int orderIndex, boolean isRequired) {
return TemplateListResponse.TemplateSectionInfo.builder()
.title(title)
.description(description)
.orderIndex(orderIndex)
.isRequired(isRequired)
.build();
}
}

View File

@ -17,9 +17,7 @@ import jakarta.validation.constraints.NotBlank;
public class SelectTemplateRequest {
@NotBlank(message = "템플릿 ID는 필수입니다")
@Schema(description = "템플릿 ID", example = "template-001", required = true)
@Schema(description = "템플릿 ID", example = "general", required = true,
allowableValues = {"general", "scrum", "kickoff", "weekly"})
private String templateId;
@Schema(description = "커스터마이징 옵션", example = "섹션 순서 변경 또는 추가 섹션 포함")
private String customization;
}

View File

@ -0,0 +1,120 @@
package com.unicorn.hgzero.meeting.infra.dto.response;
import com.unicorn.hgzero.meeting.biz.dto.MeetingEndDTO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
/**
* 회의 종료 응답 DTO
*/
@Getter
@Builder
@Schema(description = "회의 종료 응답")
public class MeetingEndResponse {
@Schema(description = "회의 제목", example = "Q1 전략 회의")
private final String title;
@Schema(description = "참석자 수", example = "4")
private final int participantCount;
@Schema(description = "회의 시간 (분)", example = "90")
private final int durationMinutes;
@Schema(description = "주요 안건 수", example = "3")
private final int agendaCount;
@Schema(description = "Todo 생성 수", example = "5")
private final int todoCount;
@Schema(description = "주요 키워드")
private final List<String> keywords;
@Schema(description = "안건별 AI 요약")
private final List<AgendaSummary> agendaSummaries;
/**
* MeetingEndDTO로부터 MeetingEndResponse 생성
*/
public static MeetingEndResponse from(MeetingEndDTO dto) {
return MeetingEndResponse.builder()
.title(dto.getTitle())
.participantCount(dto.getParticipantCount())
.durationMinutes(dto.getDurationMinutes())
.agendaCount(dto.getAgendaCount())
.todoCount(dto.getTodoCount())
.keywords(dto.getKeywords())
.agendaSummaries(dto.getAgendaSummaries().stream()
.map(AgendaSummary::from)
.toList())
.build();
}
@Getter
@Builder
@Schema(description = "안건 요약 정보")
public static class AgendaSummary {
@Schema(description = "안건 제목", example = "1. 신제품 기획 방향성")
private final String title;
@Schema(description = "AI 요약 (간략)", example = "타겟 고객을 20-30대로 설정, UI/UX 개선 집중")
private final String aiSummaryShort;
@Schema(description = "상세 내용")
private final AgendaDetails details;
@Schema(description = "Todo 목록")
private final List<TodoSummary> todos;
public static AgendaSummary from(MeetingEndDTO.AgendaSummaryDTO dto) {
return AgendaSummary.builder()
.title(dto.getTitle())
.aiSummaryShort(dto.getAiSummaryShort())
.details(AgendaDetails.from(dto.getDetails()))
.todos(dto.getTodos().stream()
.map(TodoSummary::from)
.toList())
.build();
}
}
@Getter
@Builder
@Schema(description = "안건 상세 내용")
public static class AgendaDetails {
@Schema(description = "논의 주제", example = "신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고...")
private final String discussion;
@Schema(description = "결정 사항")
private final List<String> decisions;
@Schema(description = "보류 사항")
private final List<String> pending;
public static AgendaDetails from(MeetingEndDTO.AgendaDetailsDTO dto) {
return AgendaDetails.builder()
.discussion(dto.getDiscussion())
.decisions(dto.getDecisions())
.pending(dto.getPending())
.build();
}
}
@Getter
@Builder
@Schema(description = "Todo 요약 정보")
public static class TodoSummary {
@Schema(description = "Todo 제목", example = "시장 조사 보고서 작성")
private final String title;
public static TodoSummary from(MeetingEndDTO.TodoSummaryDTO dto) {
return TodoSummary.builder()
.title(dto.getTitle())
.build();
}
}
}

View File

@ -29,6 +29,7 @@ public class TemplateListResponse {
private String name;
private String description;
private String category;
private String icon; // 아이콘 추가
private boolean isActive;
private int usageCount;
private LocalDateTime createdAt;

View File

@ -0,0 +1,58 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingAnalysisReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingAnalysisWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingAnalysisEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingAnalysisJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.Optional;
/**
* 회의 분석 Gateway
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class MeetingAnalysisGateway implements MeetingAnalysisReader, MeetingAnalysisWriter {
private final MeetingAnalysisJpaRepository repository;
@Override
public Optional<MeetingAnalysis> findByMeetingId(String meetingId) {
log.debug("Finding meeting analysis by meetingId: {}", meetingId);
return repository.findByMeetingId(meetingId)
.map(MeetingAnalysisEntity::toDomain);
}
@Override
public Optional<MeetingAnalysis> findById(String analysisId) {
log.debug("Finding meeting analysis by analysisId: {}", analysisId);
return repository.findById(analysisId)
.map(MeetingAnalysisEntity::toDomain);
}
@Override
public Optional<MeetingAnalysis> findByMinutesId(String minutesId) {
log.debug("Finding meeting analysis by minutesId: {}", minutesId);
return repository.findByMinutesId(minutesId)
.map(MeetingAnalysisEntity::toDomain);
}
@Override
public MeetingAnalysis save(MeetingAnalysis analysis) {
log.debug("Saving meeting analysis: {}", analysis.getAnalysisId());
MeetingAnalysisEntity entity = MeetingAnalysisEntity.fromDomain(analysis);
MeetingAnalysisEntity savedEntity = repository.save(entity);
return savedEntity.toDomain();
}
@Override
public void delete(String analysisId) {
log.debug("Deleting meeting analysis: {}", analysisId);
repository.deleteById(analysisId);
}
}

View File

@ -0,0 +1,84 @@
package com.unicorn.hgzero.meeting.infra.gateway.entity;
import com.unicorn.hgzero.meeting.biz.domain.MeetingAnalysis;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 회의 분석 결과 Entity
*/
@Entity
@Table(name = "meeting_analysis")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MeetingAnalysisEntity {
@Id
@Column(name = "analysis_id")
private String analysisId;
@Column(name = "meeting_id", nullable = false)
private String meetingId;
@Column(name = "minutes_id", nullable = false)
private String minutesId;
@ElementCollection
@CollectionTable(name = "meeting_keywords", joinColumns = @JoinColumn(name = "analysis_id"))
@Column(name = "keyword")
private List<String> keywords;
@Column(name = "agenda_analyses", columnDefinition = "TEXT")
private String agendaAnalysesJson; // JSON 문자열로 저장
@Column(name = "status")
private String status;
@Column(name = "completed_at")
private LocalDateTime completedAt;
@Column(name = "created_at")
private LocalDateTime createdAt;
/**
* Entity를 도메인으로 변환
*/
public MeetingAnalysis toDomain() {
// JSON 파싱은 실제 구현에서는 ObjectMapper 사용
// 현재는 Mock으로 처리
return MeetingAnalysis.builder()
.analysisId(this.analysisId)
.meetingId(this.meetingId)
.minutesId(this.minutesId)
.keywords(this.keywords)
.agendaAnalyses(List.of()) // Mock
.status(this.status)
.completedAt(this.completedAt)
.createdAt(this.createdAt)
.build();
}
/**
* 도메인에서 Entity로 변환
*/
public static MeetingAnalysisEntity fromDomain(MeetingAnalysis domain) {
return MeetingAnalysisEntity.builder()
.analysisId(domain.getAnalysisId())
.meetingId(domain.getMeetingId())
.minutesId(domain.getMinutesId())
.keywords(domain.getKeywords())
.agendaAnalysesJson("{}") // Mock JSON
.status(domain.getStatus())
.completedAt(domain.getCompletedAt())
.createdAt(domain.getCreatedAt())
.build();
}
}

View File

@ -0,0 +1,24 @@
package com.unicorn.hgzero.meeting.infra.gateway.repository;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingAnalysisEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 회의 분석 JPA Repository
*/
@Repository
public interface MeetingAnalysisJpaRepository extends JpaRepository<MeetingAnalysisEntity, String> {
/**
* 회의 ID로 분석 결과 조회
*/
Optional<MeetingAnalysisEntity> findByMeetingId(String meetingId);
/**
* 회의록 ID로 분석 결과 조회
*/
Optional<MeetingAnalysisEntity> findByMinutesId(String minutesId);
}