main 브랜치 최신 변경사항 병합

- 유저스토리 v2.3.0 업데이트 반영
- UI/UX 프로토타입 개선사항 반영
- Meeting 서비스 기능 추가 및 개선
- Notification 서비스 개선
- User 서비스 LDAP 인증 추가
- 공통 모듈 에러 코드 추가

🤖 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-27 13:18:45 +09:00
commit d43c9f0130
143 changed files with 34105 additions and 13358 deletions

14
.gitignore vendored
View File

@ -1,8 +1,12 @@
# Build outputs # Build outputs
build/ build/*
build/*/*
build/*/*/*
**/build/ **/build/
.gradle/ .gradle/
**/.gradle/ **/.gradle/
.vscode/
**/.vscode/
# Serena # Serena
serena/ serena/
@ -37,3 +41,11 @@ examples/
# Claude settings # Claude settings
.claude/settings.local.json .claude/settings.local.json
# Backup files
design/*/*/*/*back*
design/*/*/*back*
design/*/*back*
design/*back*
backup/
.vscode/settings.json

View File

@ -0,0 +1,545 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의 종료 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
/* 페이지 특화 스타일 */
.page-header {
text-align: center;
padding: var(--space-lg) 0;
background: var(--primary-light);
margin: calc(var(--space-md) * -1) calc(var(--space-md) * -1) var(--space-lg);
}
.page-header h1 {
font-size: var(--font-h2);
color: var(--primary);
margin-bottom: var(--space-sm);
}
.page-header .meeting-title {
font-size: var(--font-body);
color: var(--gray-700);
}
/* 통계 카드 그리드 - 정보 표시용 (인터랙션 없음) */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
margin-bottom: var(--space-lg);
}
.stat-card {
min-height: 80px;
padding: var(--space-md);
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
text-align: center;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.stat-value {
font-size: var(--font-h1);
font-weight: var(--font-weight-bold);
color: var(--gray-900);
margin-bottom: var(--space-xs);
line-height: 1.2;
}
.stat-label {
font-size: var(--font-small);
color: var(--gray-600);
font-weight: var(--font-weight-medium);
}
/* 키워드 클라우드 */
.keyword-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md);
}
.keyword-tag {
display: inline-block;
padding: 6px 12px;
background: var(--primary-light);
color: var(--primary-dark);
border-radius: 16px;
font-size: var(--font-small);
font-weight: var(--font-weight-medium);
}
/* 안건 카드 */
.agenda-card {
background: var(--white);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
margin-bottom: var(--space-md);
overflow: hidden;
border: 1px solid var(--gray-200);
}
.agenda-header {
padding: var(--space-md);
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
background: var(--gray-50);
}
.agenda-header:hover {
background: var(--gray-100);
}
.agenda-title {
font-size: var(--font-h4);
font-weight: var(--font-weight-bold);
color: var(--gray-900);
margin-bottom: var(--space-xs);
}
.ai-summary-short {
background: var(--gray-100);
border-left: 4px solid var(--primary);
padding: var(--space-sm) var(--space-md);
margin-top: var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--font-small);
color: var(--gray-700);
display: flex;
align-items: center;
gap: var(--space-sm);
}
.ai-summary-short .lock-icon {
color: var(--gray-500);
font-size: 16px;
}
.expand-icon {
font-size: 20px;
color: var(--gray-500);
transition: transform 0.3s ease;
}
.agenda-card.expanded .expand-icon {
transform: rotate(180deg);
}
.agenda-content {
display: none;
padding: var(--space-md);
border-top: 1px solid var(--gray-200);
}
.agenda-card.expanded .agenda-content {
display: block;
}
.agenda-section {
margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--gray-200);
}
.agenda-section:last-child {
border-bottom: none;
padding-bottom: 0;
}
.agenda-section-title {
font-size: var(--font-small);
font-weight: var(--font-weight-bold);
color: var(--gray-900);
margin-bottom: var(--space-sm);
display: flex;
align-items: center;
gap: var(--space-xs);
}
.agenda-section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
background: var(--primary);
border-radius: 2px;
}
.agenda-section-content {
font-size: var(--font-body);
color: var(--gray-700);
line-height: 1.6;
padding-left: var(--space-md);
}
.agenda-section-content ul {
margin: 0;
padding-left: var(--space-lg);
}
.agenda-section-content li {
margin-bottom: var(--space-xs);
}
/* 하단 액션 바 - 3개 버튼 배치 */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--white);
padding: var(--space-md);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
display: flex;
gap: var(--space-sm);
z-index: 100;
}
.action-bar .btn {
flex: 1;
}
.action-bar .btn-primary {
flex: 2; /* 바로 최종 확정 버튼 강조 */
}
@media (min-width: 768px) {
.stats-grid {
grid-template-columns: repeat(4, 1fr);
}
.action-bar {
position: static;
box-shadow: none;
justify-content: center;
gap: var(--space-md);
}
.action-bar .btn {
flex: 0 1 200px;
}
.action-bar .btn-primary {
flex: 0 1 250px;
}
}
.readonly-notice {
background: var(--warning-light);
border: 1px solid var(--warning);
border-radius: var(--radius-md);
padding: var(--space-md);
margin-bottom: var(--space-lg);
font-size: var(--font-small);
color: var(--warning-dark);
text-align: center;
}
</style>
</head>
<body>
<div class="page">
<div class="container has-action-bar">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1>✅ 회의가 종료되었습니다</h1>
<p class="meeting-title">2025년 1분기 제품 기획 회의</p>
</div>
<!-- 읽기 전용 안내 -->
<div class="readonly-notice">
🔒 이 화면은 <strong>확인 전용</strong>입니다. 내용을 수정하려면 "회의록 수정" 버튼을 클릭하세요.
</div>
<!-- 통계 카드 그리드 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="durationValue">0</div>
<div class="stat-label">회의 시간 (분)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="participantsValue">0</div>
<div class="stat-label">참석자</div>
</div>
<div class="stat-card">
<div class="stat-value" id="agendasValue">0</div>
<div class="stat-label">안건</div>
</div>
<div class="stat-card">
<div class="stat-value" id="todosValue">0</div>
<div class="stat-label">Todo</div>
</div>
</div>
<!-- 주요 키워드 -->
<div class="card mb-md">
<h3 class="card-title">주요 키워드</h3>
<div class="keyword-cloud">
<span class="keyword-tag">신제품 기획</span>
<span class="keyword-tag">예산 편성</span>
<span class="keyword-tag">일정 조율</span>
<span class="keyword-tag">시장 조사</span>
<span class="keyword-tag">UI/UX</span>
<span class="keyword-tag">개발 스펙</span>
</div>
</div>
<!-- 안건별 AI 요약 -->
<div class="card mb-md">
<h3 class="card-title">안건별 AI 요약</h3>
<div id="agendaList"></div>
</div>
</div>
<!-- 하단 액션 바 (3가지 선택 옵션) -->
<div class="action-bar">
<button class="btn btn-ghost" onclick="navigateTo('02-대시보드.html')">
대시보드
</button>
<button class="btn btn-secondary" onclick="navigateTo('11-회의록수정.html')">
회의록 수정
</button>
<button class="btn btn-primary" onclick="confirmMeetingDirectly()">
바로 최종 확정
</button>
</div>
</div>
<script src="common.js"></script>
<script>
// 샘플 안건 데이터
const SAMPLE_AGENDAS = [
{
id: 'agenda-1',
title: '1. 신제품 기획 방향성',
aiSummaryShort: '타겟 고객을 20-30대로 설정, UI/UX 개선 집중',
details: {
discussion: '신제품의 주요 타겟 고객층을 20-30대 직장인으로 설정하고, 기존 제품 대비 UI/UX를 대폭 개선하기로 함',
opinions: [
{ speaker: '김민준', opinion: '타겟 고객층을 명확히 설정하여 마케팅 전략 수립 필요' },
{ speaker: '박서연', opinion: 'UI/UX 개선에 AI 기술 적용 검토' }
],
decisions: ['타겟 고객: 20-30대 직장인', 'UI/UX 개선을 최우선 과제로 설정'],
pending: []
},
todos: [
{
title: '시장 조사 보고서 작성',
assignee: SAMPLE_MEETINGS[0].participants[0],
dueDate: '2025-11-01',
priority: 'high'
},
{
title: 'UI/UX 개선안 초안 작성',
assignee: SAMPLE_MEETINGS[0].participants[1],
dueDate: '2025-11-05',
priority: 'medium'
}
]
},
{
id: 'agenda-2',
title: '2. 예산 편성 및 일정',
aiSummaryShort: '총 예산 5억, 개발 기간 6개월 확정',
details: {
discussion: '신제품 개발을 위한 총 예산을 5억원으로 책정하고, 개발 기간은 6개월로 확정함',
opinions: [
{ speaker: '이준호', opinion: '개발 기간 6개월은 타이트하므로 우선순위 명확화 필요' },
{ speaker: '최유진', opinion: '예산 배분은 개발 60%, 마케팅 40%로 제안' }
],
decisions: ['총 예산: 5억원', '개발 기간: 6개월', '예산 배분: 개발 60%, 마케팅 40%'],
pending: ['세부 일정 확정은 다음 회의에서 논의']
},
todos: [
{
title: '세부 개발 일정 수립',
assignee: SAMPLE_MEETINGS[0].participants[2],
dueDate: '2025-10-28',
priority: 'high'
}
]
},
{
id: 'agenda-3',
title: '3. 기술 스택 및 개발 방향',
aiSummaryShort: 'React 기반 프론트엔드, AI 챗봇 기능 추가',
details: {
discussion: '프론트엔드는 React 기반으로 개발하고, 고객 지원을 위한 AI 챗봇 기능을 추가하기로 함',
opinions: [
{ speaker: '박서연', opinion: 'AI 챗봇은 GPT-4 기반으로 개발 제안' },
{ speaker: '이준호', opinion: 'React 외에 Next.js 도입 검토 필요' }
],
decisions: ['프론트엔드: React 기반', 'AI 챗봇 기능 추가', 'Next.js 도입 검토'],
pending: ['AI 챗봇 학습 데이터 확보 방안']
},
todos: [
{
title: 'AI 챗봇 프로토타입 개발',
assignee: SAMPLE_MEETINGS[0].participants[1],
dueDate: '2025-11-10',
priority: 'medium'
},
{
title: 'Next.js 도입 검토 보고서',
assignee: SAMPLE_MEETINGS[0].participants[3],
dueDate: '2025-11-03',
priority: 'low'
}
]
}
];
// 페이지 초기화
function initPage() {
// 통계 카운트 애니메이션
animateCounter('durationValue', 90);
animateCounter('participantsValue', 4);
animateCounter('agendasValue', SAMPLE_AGENDAS.length);
// Todo 전체 개수 계산
const totalTodos = SAMPLE_AGENDAS.reduce((sum, agenda) => sum + (agenda.todos?.length || 0), 0);
animateCounter('todosValue', totalTodos);
// 안건 리스트 렌더링
renderAgendaList();
}
// 카운터 애니메이션
function animateCounter(elementId, target) {
const element = $(`#${elementId}`);
let current = 0;
const increment = target / 30;
const timer = setInterval(() => {
current += increment;
if (current >= target) {
element.textContent = target;
clearInterval(timer);
} else {
element.textContent = Math.floor(current);
}
}, 30);
}
// 안건 리스트 렌더링
function renderAgendaList() {
const container = $('#agendaList');
SAMPLE_AGENDAS.forEach(agenda => {
// 안건 카드
const card = createElement('div', {
className: 'agenda-card',
id: `agenda-${agenda.id}`,
onclick: `toggleAgenda('${agenda.id}')`
});
// 헤더
const header = createElement('div', { className: 'agenda-header' }, `
<div>
<div class="agenda-title">${agenda.title}</div>
<div class="ai-summary-short">
<span class="lock-icon">🔒</span>
<span>${agenda.aiSummaryShort}</span>
</div>
</div>
<div class="expand-icon"></div>
`);
card.appendChild(header);
// 상세 내용
const content = createElement('div', { className: 'agenda-content' });
// 논의 주제
if (agenda.details.discussion) {
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">논의 주제</div>
<div class="agenda-section-content">${agenda.details.discussion}</div>
`));
}
// 결정 사항
if (agenda.details.decisions && agenda.details.decisions.length > 0) {
const decisionsHtml = agenda.details.decisions.map(d => `<li>✓ ${d}</li>`).join('');
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">결정 사항</div>
<div class="agenda-section-content"><ul>${decisionsHtml}</ul></div>
`));
}
// 보류 사항
if (agenda.details.pending && agenda.details.pending.length > 0) {
const pendingHtml = agenda.details.pending.map(p => `<li>⏸ ${p}</li>`).join('');
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">보류 사항</div>
<div class="agenda-section-content"><ul>${pendingHtml}</ul></div>
`));
}
// Todo 목록 - 제목만 간단히 표시
if (agenda.todos && agenda.todos.length > 0) {
const todosSection = createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">Todo 자동 추출 결과</div>
`);
const todoList = createElement('ul', {
style: 'list-style: none; padding: 0; margin: 0;'
});
agenda.todos.forEach(todo => {
const todoItem = createElement('li', {
style: 'display: flex; align-items: center; gap: var(--space-sm); padding: var(--space-sm) 0; font-size: var(--font-body); color: var(--gray-700);'
}, `
<span style="color: var(--gray-500);"></span>
<span>${todo.title}</span>
`);
todoList.appendChild(todoItem);
});
todosSection.appendChild(todoList);
// 안내 문구 추가
const notice = createElement('p', {
style: 'font-size: var(--font-small); color: var(--gray-500); margin-top: var(--space-md); padding-top: var(--space-sm); border-top: 1px solid var(--gray-200);'
}, '💡 담당자 및 마감일은 회의록 수정 화면에서 지정할 수 있습니다.');
todosSection.appendChild(notice);
content.appendChild(todosSection);
}
card.appendChild(content);
container.appendChild(card);
});
}
// 안건 카드 확장/축소
function toggleAgenda(agendaId) {
const card = $(`#agenda-${agendaId}`);
card.classList.toggle('expanded');
}
// 바로 최종 확정
function confirmMeetingDirectly() {
if (confirm('AI가 정리한 내용 그대로 최종 확정하시겠습니까?\n\n모든 안건이 자동으로 검증 완료 처리되며, 참석자에게 확정 알림이 발송됩니다.')) {
showToast('회의록이 최종 확정되었습니다', 'success');
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 1000);
}
}
// 페이지 로드 시 초기화
initPage();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,88 @@
# UI/UX 설계서 v1.4.20 업데이트 요약
## 업데이트 일시
2025-10-25
## 변경 사항
### 1. 회의 종료 화면 (07-회의종료) - "옵션 2: 바로 최종 확정" 정책 명확화
**위치**: 인터랙션 섹션, 라인 958-976
**변경 내용**:
- UFR-MEET-050 시나리오 2 명시 추가
- 확인 다이얼로그 메시지 구체화: "바로 최종 확정하시겠습니까? AI가 정리한 내용 그대로 확정됩니다."
- 안건별 검증완료 처리 단계 추가
- 회의록 상태 변경 명확화: "작성중" → "확정완료"
- 이동 페이지 변경: 02-대시보드.html → 10-회의록상세조회.html
- 시나리오 2 특징 상세 설명 추가:
- 회의록 수정 단계를 건너뜀
- AI 생성 내용을 그대로 확정
- 모든 안건이 자동으로 검증완료 처리됨
- 확정 후에도 회의 생성자는 수정 가능 (잠금 해제 필요)
### 2. 회의록 수정 화면 (11-회의록수정) - 안건 기반 충돌 해결 메커니즘 추가
**위치**: 인터랙션 섹션, 라인 1531 이후 (새로운 섹션 9 추가)
**변경 내용**:
- UFR-COLLAB-020 안건 기반 충돌 방지 메커니즘 상세 추가
- **안건 기반 충돌 방지 메커니즘**:
- **다른 안건 동시 편집**: 충돌 없음 (참석자 A는 안건 1, 참석자 B는 안건 2)
- **동일 안건 내 다른 필드 편집**: 자동 병합 (상세 요약, 관련회의록 등)
- **동일 필드 동시 수정**: Last Write Wins 방식 (덮어쓰기 경고 + 선택 옵션)
- **편집 중 표시**:
- 다른 사용자 편집 중인 안건 표시
- 편집자 아바타 + 이름 실시간 표시
- 예: "김민준님이 이 안건을 편집 중입니다" + 아바타
- **충돌 경고 모달**:
- 제목: "동시 수정 감지"
- 메시지: "다른 사용자가 이미 이 내용을 수정했습니다"
- 옵션: "최신 내용 보기" / "내 변경사항 유지"
### 3. 회의록 수정 화면 (11-회의록수정) - UI 구성요소에 편집 중 표시 추가
**위치**: UI 구성요소 > 안건 헤더, 라인 1389-1394
**변경 내용**:
- 안건 헤더에 편집 중 표시 추가:
- 다른 사용자 아바타 + 이름
- 예: "김민준님 편집 중" (아이콘 + 텍스트)
### 4. 회의록 수정 화면 (11-회의록수정) - 에러 처리 섹션 업데이트
**위치**: 에러 처리 섹션
**변경 내용**:
- 충돌 발생 에러 처리 상세화:
- 안건 기반 충돌 방지로 최소화
- 동일 필드 동시 수정 시 경고 모달 표시
- 선택 옵션 제공: 최신 내용 확인 / 내 변경사항 유지
- 병합 실패 시 에러 메시지: "병합 중 오류가 발생했습니다"
### 5. 변경 이력 추가
**위치**: 변경 이력 테이블
**변경 내용**:
- v1.4.20 (2025-10-25) 항목 추가:
- 유저스토리 v2.3.0 반영
- 회의 종료 화면 정책 명확화 (확인 전용, 바로 최종 확정 옵션 상세화)
- UFR-MEET-050: 최종 확정 2가지 시나리오 설명 추가
- UFR-COLLAB-020: 안건 기반 충돌 해결 메커니즘 상세 추가
- 실시간 협업 충돌 방지 정책 강화
## 업데이트 방법
- Python 스크립트를 이용한 자동 업데이트
- 5개의 주요 업데이트 항목 모두 성공적으로 적용됨
## 검증 완료
- ✓ Update 1: 옵션 2: 바로 최종 확정 - Updated
- ✓ Update 2: 안건 기반 충돌 해결 섹션 - Added
- ✓ Update 3: 안건 헤더에 편집 중 표시 - Added
- ✓ Update 4: 충돌 처리 업데이트 - Updated
- ✓ Update 5: 변경 이력 추가 - Added
## 관련 유저스토리
- UFR-MEET-050: 회의록 최종 확정
- UFR-COLLAB-020: 실시간 협업 및 충돌 해결

View File

@ -0,0 +1,217 @@
# 유저스토리 v2.2.0 → v2.3.0 변경사항 요약
## 📊 한눈에 보는 변경사항
```
v2.2.0 (25개) v2.3.0 (27개)
┌─────────────────┐ ┌─────────────────┐
│ AFR-USER-010 │ ──────────────────>│ UFR-USER-010 ✨ │ (로그인 상세화)
│ AFR-USER-020 │ ──────────────────>│ UFR-USER-020 ✨ │ (대시보드 재설계)
├─────────────────┤ ├─────────────────┤
│ UFR-MEET-010 │ ──────────────────>│ UFR-MEET-010 ✨ │ (회의예약 개선)
│ │ │ UFR-MEET-015 🆕 │ (참석자 실시간 초대)
│ UFR-MEET-020 │ ──────────────────>│ UFR-MEET-020 ✨ │ (템플릿선택 상세화)
│ UFR-MEET-030 │ ──────────────────>│ UFR-MEET-030 ✨ │ (회의시작 4개 탭)
│ UFR-MEET-040 │ ──────────────────>│ UFR-MEET-040 ✨ │ (회의종료 3가지 액션)
│ UFR-MEET-050 │ ──────────────────>│ UFR-MEET-050 ✨ │ (최종확정 2가지 시나리오)
│ UFR-MEET-046 │ ──────────────────>│ UFR-MEET-046 ✨ │ (목록조회 샘플 30개)
│ UFR-MEET-047 │ ──────────────────>│ UFR-MEET-047 ✨ │ (상세조회 관련회의록)
│ UFR-MEET-055 │ ──────────────────>│ UFR-MEET-055 ✨ │ (회의록수정 3가지 시나리오)
├─────────────────┤ ├─────────────────┤
│ UFR-AI-010 │ ──────────────────>│ UFR-AI-010 │
│ UFR-AI-020 │ ──────────────────>│ UFR-AI-020 │
│ │ │ UFR-AI-030 🆕🎯 │ (실시간 AI 제안 - 차별화!)
│ UFR-AI-035 │ ──────────────────>│ UFR-AI-035 │
│ UFR-AI-036 │ ──────────────────>│ UFR-AI-036 │
│ UFR-AI-040 │ ──────────────────>│ UFR-AI-040 │
├─────────────────┤ ├─────────────────┤
│ UFR-STT-010 │ ──────────────────>│ UFR-STT-010 │
│ UFR-STT-020 │ ──────────────────>│ UFR-STT-020 │
├─────────────────┤ ├─────────────────┤
│ UFR-RAG-010 │ ──────────────────>│ UFR-RAG-010 │
│ UFR-RAG-020 │ ──────────────────>│ UFR-RAG-020 │
├─────────────────┤ ├─────────────────┤
│ UFR-COLLAB-010 │ ──────────────────>│ UFR-COLLAB-010 │
│ UFR-COLLAB-020 │ ──────────────────>│ UFR-COLLAB-020 │
│ UFR-COLLAB-030 │ ──────────────────>│ UFR-COLLAB-030 │
├─────────────────┤ ├─────────────────┤
│ UFR-TODO-010 │ ──────────────────>│ UFR-TODO-010 │
│ UFR-TODO-030 │ ──────────────────>│ UFR-TODO-030 │
│ UFR-TODO-040 │ ──────────────────>│ UFR-TODO-040 │
└─────────────────┘ ├─────────────────┤
│ UFR-NOTI-010 🆕 │ (알림발송 - 폴링 방식)
└─────────────────┘
범례:
🆕 = 완전 신규 추가
🎯 = 차별화 핵심 기능
✨ = 대폭 개선 (프로토타입 기반 재작성)
```
---
## 🎯 3대 신규 기능
### 1. UFR-MEET-015: 참석자 실시간 초대 🆕
- **위치**: 회의 진행 화면 "참석자" 탭
- **기능**: 회의 중 검색 모달로 참석자 추가 → 실시간 동기화 → 알림 발송
- **의미**: 회의 진행 중 동적 참석자 관리로 유연성 향상
### 2. UFR-AI-030: 실시간 AI 제안 🆕🎯
- **위치**: 회의 진행 화면 "AI 제안" 탭
- **기능**: STT 텍스트 실시간 분석 → 주요 내용 감지 → 제안 카드 생성 → 메모에 추가
- **의미**: **차별화 전략 "지능형 회의 진행 지원" 실현**
- **효과**: 회의 중 놓치는 내용 최소화
### 3. UFR-NOTI-010: 알림 발송 🆕
- **방식**: 폴링 (1분 간격) → 이메일 발송 → 최대 3회 재시도
- **알림 유형**: Todo 할당, Todo 완료, 회의 시작, 회의록 확정, 참석자 초대, 회의록 수정
- **의미**: **알림 아키텍처 폴링 방식으로 통일** → Notification 서비스 독립성 확보
---
## 📈 유저스토리 품질 개선
### 형식 표준화 (Before & After)
#### v2.2.0 (자유 형식)
```
UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, ...
- 시나리오: 회의 예약 및 참석자 초대
회의 예약 화면에 접근한 상황에서 | ...
[입력 요구사항]
- 회의 제목: 최대 100자 (필수)
...
[처리 결과]
- 회의가 예약됨
...
- M/13
```
#### v2.3.0 (표준 형식)
```
### UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, ...
**수행절차:**
1. 대시보드에서 "회의예약" FAB 버튼 클릭
2. 회의 제목 입력 (최대 100자)
3. 날짜 선택 (오늘 이후 날짜, 달력 UI)
...
10. "임시저장" 버튼 또는 "예약 완료" 버튼 클릭
**입력:**
- 회의 제목: 텍스트 입력, 필수, 최대 100자, 문자 카운터 표시
- 날짜: date 타입, 필수, 오늘 이후 날짜만 선택 가능
...
**출력/결과:**
- 예약 완료: "회의가 예약되었습니다" 토스트 메시지, 대시보드로 이동
- 임시저장: "임시 저장되었습니다" 토스트 메시지
...
**예외처리:**
- 제목 미입력: "회의 제목을 입력해주세요" 토스트, 제목 필드 포커스
- 과거 날짜 선택: "과거 날짜는 선택할 수 없습니다" 토스트
...
**관련 유저스토리:**
- UFR-USER-020: 대시보드 조회
- UFR-MEET-020: 템플릿선택
```
### 개선 효과
- ✅ **수행절차**: 단계별 명확한 작업 흐름
- ✅ **입력**: 필드 타입, 검증 규칙, UI 요소 상세 명세
- ✅ **출력/결과**: 성공/실패 시나리오별 응답 명시
- ✅ **예외처리**: 에러 상황별 처리 방법 구체화
- ✅ **관련 유저스토리**: 기능 간 연계성 추적
---
## 🏗️ 프로토타입 연계 강화
| 프로토타입 화면 | 연계 유저스토리 | 상태 |
|----------------|----------------|------|
| 01-로그인.html | UFR-USER-010 | ✅ 1:1 매핑 |
| 02-대시보드.html | UFR-USER-020 | ✅ 1:1 매핑 |
| 03-회의예약.html | UFR-MEET-010 | ✅ 1:1 매핑 |
| 04-템플릿선택.html | UFR-MEET-020 | ✅ 1:1 매핑 |
| 05-회의진행.html | UFR-MEET-030, UFR-MEET-015 (신규), UFR-AI-030 (신규) | ✅ 1:N 매핑 |
| 07-회의종료.html | UFR-MEET-040 | ✅ 1:1 매핑 |
| 10-회의록상세조회.html | UFR-MEET-047 | ✅ 1:1 매핑 |
| 11-회의록수정.html | UFR-MEET-055 | ✅ 1:1 매핑 |
| 12-회의록목록조회.html | UFR-MEET-046 | ✅ 1:1 매핑 |
**결과**: 10개 프로토타입 화면 100% 유저스토리 연계 완료
---
## 🔑 핵심 아키텍처 변경
### 알림 아키텍처: 실시간 → 폴링 방식
#### Before (v2.2.0)
```
[Meeting Service] ──(실시간 발송)──> [Notification Service] ──> [Email]
Todo 할당 발생 → 즉시 이메일 발송
```
#### After (v2.3.0)
```
[Meeting Service] ──(DB 레코드 생성)──> [Notification 테이블]
(1분 간격 폴링)
[Notification Service] ──> [Email]
(발송 상태 업데이트)
```
**개선 효과**:
- ✅ **Notification 서비스 독립성 강화**: 마이크로서비스 간 느슨한 결합
- ✅ **시스템 안정성 향상**: 이메일 발송 실패 시 자동 재시도 (최대 3회)
- ✅ **확장성 확보**: 폴링 주기 조정으로 트래픽 제어 가능
---
## 📊 통계 비교
| 항목 | v2.2.0 | v2.3.0 | 변화 |
|------|--------|--------|------|
| **유저스토리 수** | 25개 | 27개 | +2개 (+8%) |
| **신규 추가** | - | 3개 | - |
| **AFR 코드** | 2개 | 0개 | -2개 (100% 제거) |
| **UFR 코드** | 23개 | 27개 | +4개 (+17%) |
| **평균 상세도** | 20-30줄 | 60-100줄 | **약 3배** |
| **프로토타입 연계** | 부분적 | 100% (10개 화면) | - |
| **표준 형식 적용** | 0% | 100% (27개) | - |
---
## ✅ 권장 후속 조치 체크리스트
### 🔴 긴급 (1주 내)
- [ ] 신규 유저스토리 3개 기반 API 설계 (UFR-MEET-015, UFR-AI-030, UFR-NOTI-010)
- [ ] 알림 아키텍처 폴링 방식 반영 (물리 아키텍처 업데이트)
- [ ] 프로토타입 ↔ 유저스토리 1:1 매핑 검증
### 🟡 중요 (2주 내)
- [ ] API 설계서 v2.3.0 기반 전면 업데이트 (입력/출력 명세 반영)
- [ ] 예외처리 시나리오 → 테스트 케이스 전환
- [ ] 관련 유저스토리 기반 통합 테스트 시나리오 작성
### 🟢 일반 (3주 내)
- [ ] 유저스토리별 개발 우선순위 재평가
- [ ] 신규 기능 3개 개발 일정 수립
- [ ] 프로토타입 기반 개발 가이드 작성
---
**분석 일시**: 2025-10-25
**분석 파일**:
- 상세 분석 (JSON): `claude/userstory-comparison-v2.2.0-to-v2.3.0.json`
- 상세 분석 (Markdown): `claude/userstory-comparison-v2.2.0-to-v2.3.0.md`

View File

@ -0,0 +1,343 @@
{
"comparisonMetadata": {
"previousVersion": "v2.2.0",
"currentVersion": "v2.3.0",
"comparisonDate": "2025-10-25",
"analyst": "Claude (AI Assistant)",
"previousVersionDate": "2025-10-23",
"currentVersionDate": "2025-10-24"
},
"documentStructure": {
"v2.2.0": {
"description": "기존 구조: 유저스토리 섹션과 논리 아키텍처 반영 사항 요약 섹션 포함",
"mainSections": [
"차별화 전략",
"마이크로서비스 구성",
"유저스토리",
"논리 아키텍처 반영 사항 요약",
"문서 이력"
],
"userStoryFormat": "계층적 구조 (서비스 > 기능 그룹 > 유저스토리)",
"userStoryPrefix": "AFR/UFR 혼용",
"totalUserStories": 25
},
"v2.3.0": {
"description": "신규 구조: 프로토타입 기반으로 재정비, 논리 아키텍처 섹션 제거, 유저스토리 형식 표준화",
"mainSections": [
"차별화 전략",
"마이크로서비스 구성",
"유저스토리 v2.3.0 - USER & MEETING 서비스",
"문서 이력"
],
"userStoryFormat": "표준화된 형식 (수행절차, 입력, 출력/결과, 예외처리, 관련 유저스토리)",
"userStoryPrefix": "UFR로 통일 (AFR 제거)",
"totalUserStories": 27
},
"changes": [
"논리 아키텍처 반영 사항 요약 섹션 삭제 (설계 문서로 이관)",
"유저스토리 형식 대폭 개선: 기존의 자유 형식에서 구조화된 템플릿으로 전환",
"모든 유저스토리에 '수행절차', '입력', '출력/결과', '예외처리', '관련 유저스토리' 섹션 추가",
"AFR 코드 제거 및 UFR로 통일 (더 이상 아키텍처 참조 코드 사용하지 않음)",
"프로토타입 화면과의 연계성 강화 (화면 번호, 파일명 명시)",
"유저스토리 ID 체계 유지 (기존 24개 ID 승계)"
]
},
"addedStories": [
{
"code": "UFR-USER-010",
"previousCode": "AFR-USER-010",
"title": "[로그인] 사용자로서 | 나는, 시스템에 접근하기 위해 | 사번과 비밀번호로 로그인하고 싶다",
"description": "기존 AFR-USER-010에서 UFR-USER-010으로 전환. 상세한 수행절차, 입력/출력 명세, 예외처리 추가",
"significance": "프로토타입 01-로그인.html과 직접 연계. 로그인 흐름, 검증 규칙, 에러 처리가 구체화됨",
"newFeatures": [
"로그인 상태 유지 체크박스 추가",
"Enter 키 입력 시 다음 필드로 자동 이동",
"비밀번호 최소 8자 검증",
"로딩 상태 UI 명시",
"이미 로그인된 경우 자동 리다이렉트"
],
"relatedPrototype": "01-로그인.html"
},
{
"code": "UFR-USER-020",
"previousCode": "AFR-USER-020",
"title": "[대시보드] 사용자로서 | 나는, 나의 회의 및 Todo 현황을 파악하기 위해 | 대시보드를 조회하고 싶다",
"description": "기존 AFR-USER-020에서 UFR-USER-020으로 전환. 대시보드 위젯 구성 재정의 및 상세 명세 추가",
"significance": "프로토타입 02-대시보드.html 기반으로 대시보드 구성 완전 재설계. 통계 블록, 최근 회의, Todo, 회의록 섹션 구체화",
"newFeatures": [
"통계 블록 2열 그리드 (예정된 회의, 나의 Todo)",
"최근 회의 목록 (회의록 미생성 우선, 최대 3개)",
"나의 Todo 목록 (미완료 우선, D-day 표시, 최대 3개)",
"나의 회의록 2x2 그리드 (최대 4개)",
"FAB 메뉴 (회의예약, 바로시작)",
"반응형 네비게이션 (데스크톱 사이드바, 모바일 하단 탭)"
],
"relatedPrototype": "02-대시보드.html"
},
{
"code": "UFR-MEET-015",
"previousCode": null,
"title": "[회의진행] 회의 참석자로서 | 나는, 회의 중 추가 참석자가 필요할 때 | 실시간으로 참석자를 초대하고 싶다",
"description": "**신규 추가**: 회의 진행 중 실시간 참석자 초대 기능",
"significance": "프로토타입 05-회의진행.html의 '참석자' 탭 기능 반영. 회의 진행 중 동적 참석자 관리 가능",
"newFeatures": [
"회의 진행 중 검색 모달을 통한 참석자 초대",
"초대된 참석자 실시간 표시",
"Notification 서비스 연동 (초대 알림 발송)",
"모든 참석자에게 WebSocket 기반 실시간 동기화"
],
"relatedPrototype": "05-회의진행.html",
"relatedUserStories": [
"UFR-MEET-030 (회의시작)",
"UFR-COLLAB-010 (회의록수정동기화)",
"UFR-NOTI-010 (알림발송)"
]
},
{
"code": "UFR-AI-030",
"previousCode": null,
"title": "[실시간AI제안] 회의 참석자로서 | 나는, 회의 중 놓치는 내용을 최소화하기 위해 | AI가 실시간으로 주요 내용을 분석하여 제안하고 싶다",
"description": "**신규 추가**: 회의 진행 중 AI 실시간 분석 및 제안 기능",
"significance": "프로토타입 05-회의진행.html의 'AI 제안' 탭 기능 구현. 회의 중 AI가 주요 내용을 감지하여 자동 제안하는 차별화 기능",
"newFeatures": [
"STT 텍스트 실시간 분석",
"주요 내용 감지 시 AI 제안 카드 자동 생성",
"'메모에 추가' 버튼으로 회의 메모에 즉시 추가",
"모든 참석자에게 실시간 동기화",
"로컬 캐시를 통한 네트워크 오류 대응"
],
"relatedPrototype": "05-회의진행.html",
"relatedUserStories": [
"UFR-STT-020 (텍스트변환)",
"UFR-MEET-030 (회의시작)",
"UFR-COLLAB-010 (회의록수정동기화)"
],
"differentiatorImpact": "지능형 회의 진행 지원의 핵심 기능으로, 회의 중 놓치는 내용 최소화"
},
{
"code": "UFR-NOTI-010",
"previousCode": null,
"title": "[알림발송] Notification 시스템으로서 | 나는, 사용자에게 중요한 이벤트를 알리기 위해 | 주기적으로 알림 대상을 확인하여 이메일을 발송하고 싶다",
"description": "**신규 추가**: 알림 시스템 폴링 방식 명세",
"significance": "알림 아키텍처를 실시간 발송에서 주기적 폴링 방식으로 통일. Notification 서비스의 독립성과 안정성 확보",
"newFeatures": [
"주기적 폴링 (1분 간격) 방식 알림 발송",
"이메일 발송 실패 시 최대 3회 재시도",
"알림 유형별 템플릿 적용",
"6가지 알림 유형 지원 (Todo 할당, Todo 완료, 회의 시작, 회의록 확정, 참석자 초대, 회의록 수정)"
],
"relatedUserStories": [
"UFR-TODO-010 (Todo할당)",
"UFR-TODO-030 (Todo완료처리)",
"UFR-MEET-015 (참석자 실시간 초대)",
"UFR-MEET-050 (최종확정)"
],
"architectureImpact": "Notification 서비스를 독립적인 폴링 기반 마이크로서비스로 명확히 정의"
}
],
"removedStories": [
{
"code": "AFR-USER-010",
"title": "[사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 기능을 원한다",
"reason": "UFR-USER-010으로 전환. AFR(아키텍처 참조) 코드 체계 폐지 및 UFR(사용자 기능 요구사항)로 통일",
"impact": "코드 변경일 뿐, 기능은 UFR-USER-010으로 승계되어 유지됨"
},
{
"code": "AFR-USER-020",
"title": "[대시보드] 사용자로서 | 나는, 회의록 서비스의 주요 정보를 한눈에 파악하기 위해 | 대시보드를 통해 요약 정보를 확인하고 싶다",
"reason": "UFR-USER-020으로 전환. 프로토타입 기반으로 상세 명세 재작성",
"impact": "코드 변경 및 내용 대폭 보강. 기능은 강화되어 승계됨"
}
],
"modifiedStories": [
{
"code": "UFR-MEET-010",
"title": "[회의예약] 회의 생성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다",
"changes": [
"프로토타입 03-회의예약.html 기반으로 전면 재작성",
"상세한 수행절차 추가 (10단계)",
"입력 필드 상세 명세 (종일 회의 토글, 온라인/오프라인 회의 토글, 회의 링크 자동 생성)",
"예외처리 8가지 추가 (과거 날짜 선택, 뒤로가기 확인 모달 등)",
"임시저장 기능 추가",
"참석자 검색 모달 UI 상세화"
],
"significance": "프로토타입과의 정확한 매칭으로 개발 시 명확한 가이드 제공"
},
{
"code": "UFR-MEET-020",
"title": "[템플릿선택] 회의 생성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다",
"changes": [
"프로토타입 04-템플릿선택.html 기반으로 재작성",
"4가지 템플릿 내용 상세 명세 (일반, 스크럼, 킥오프, 주간 회의)",
"건너뛰기 옵션 추가",
"템플릿 미리보기 구성 명시 (아이콘, 설명, 섹션 목록)"
],
"significance": "템플릿별 섹션 구성이 구체화되어 일관된 회의록 작성 지원"
},
{
"code": "UFR-MEET-030",
"title": "[회의시작] 회의 생성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다",
"changes": [
"프로토타입 05-회의진행.html 기반으로 전면 재작성",
"8단계 상세 수행절차 추가",
"4개 탭 네비게이션 명시 (참석자, AI 제안, 용어사전, 관련회의록)",
"웨이브폼 애니메이션, 타이머, 녹음 상태 UI 추가",
"하단 고정 메모 영역 추가",
"일시정지 및 종료 확인 모달 추가"
],
"significance": "회의 진행 화면의 핵심 UX가 상세히 정의되어 실시간 협업 기능 구현 가이드 제공"
},
{
"code": "UFR-MEET-040",
"title": "[회의종료] 회의 생성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 요약 내용을 확인한 후 다음 단계를 선택하고 싶다",
"changes": [
"프로토타입 07-회의종료.html 기반으로 재작성",
"통계 카드 4개 명시 (참석자, 시간, 안건, Todo)",
"주요 키워드 태그 표시 추가",
"안건별 아코디언 카드 구조 명시 (AI 한줄 요약 + 상세 요약 + Todo)",
"읽기 전용 안내 표시",
"하단 액션 바 3가지 옵션 명시 (회의록 수정, 바로 최종 확정, 대시보드)"
],
"significance": "회의 종료 후 워크플로우가 명확해져 사용자 선택권 확대 및 UX 개선"
},
{
"code": "UFR-MEET-050",
"title": "[최종확정] 회의 생성자로서 | 나는, 회의록을 완성하기 위해 | 모든 안건을 검증하고 최종 회의록을 확정하고 싶다",
"changes": [
"2가지 시나리오로 분리 (회의록 수정 후 확정, 회의 종료 화면에서 바로 확정)",
"각 시나리오별 수행절차 5-6단계 상세화",
"확인 모달 메시지 구체화",
"바로 확정 시 모든 안건 자동 검증 완료 처리 로직 추가",
"확정 후 편집 권한 정책 명시 (회의 생성자만 잠금 해제 후 수정 가능)"
],
"significance": "유연한 확정 워크플로우 제공으로 사용자 편의성 향상"
},
{
"code": "UFR-MEET-046",
"title": "[회의록목록조회] 회의 참석자로서 | 나는, 참여한 회의록들을 효율적으로 관리하기 위해 | 회의록 목록을 조회하고 필터링하고 싶다",
"changes": [
"프로토타입 12-회의록목록조회.html 기반으로 재작성",
"데이터 소스 명시 (common.js → SAMPLE_MINUTES 배열)",
"필터링 옵션 상세화 (상태별, 정렬, 참여 유형, 검색)",
"통계 표시 추가",
"페이지네이션 방식 명시 (초기 10개, '10개 더보기' 버튼)",
"목록 표시 정보 8가지 추가",
"우선순위 M → S로 변경 (MVP 집중)"
],
"significance": "프로토타입 연계 강화, 샘플 데이터 30개 기반 개발 가능"
},
{
"code": "UFR-MEET-047",
"title": "[회의록상세조회] 회의 참석자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다",
"changes": [
"프로토타입 10-회의록상세조회.html 기반으로 재작성",
"회의 기본 정보 표시 항목 7가지 상세화",
"섹션별 상세 내용 표시 구조 추가",
"관련 회의록 섹션 추가 (최대 3개, 관련도 % 표시)",
"탭 네비게이션 구성 명시 (대시보드, 회의록 2개 탭)"
],
"significance": "회의록 조회 화면 정보 구조 명확화 및 관련 회의록 연결 강화"
},
{
"code": "UFR-MEET-055",
"title": "[회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 수정하고 싶다",
"changes": [
"프로토타입 11-회의록수정.html 기반으로 전면 재작성",
"3가지 시나리오로 확장 (작성중 회의록 수정, 확정완료 회의록 수정, 안건 검증)",
"각 시나리오별 상세 수행절차 추가",
"잠금 해제 메커니즘 명시 (확정완료 회의록의 경우)",
"검증 완료 프로세스 상세화 (안건별 체크 버튼, 검증률 표시)"
],
"significance": "회의록 수정 워크플로우가 상태별로 명확해져 협업 시나리오 지원 강화"
}
],
"overallImpact": {
"userExperience": {
"improvements": [
"프로토타입 기반 유저스토리로 실제 사용 흐름과 정확히 일치",
"상세한 수행절차로 사용자 작업 흐름 명확화",
"예외처리 시나리오 추가로 에러 상황 대응 개선",
"실시간 AI 제안 기능으로 회의 중 놓치는 내용 최소화",
"유연한 확정 워크플로우로 사용자 선택권 확대",
"회의 진행 중 참석자 실시간 초대로 협업 유연성 향상"
],
"keyEnhancements": [
"대시보드 재설계로 정보 접근성 향상 (통계 블록, 최근 회의, Todo, 회의록 4개 섹션)",
"회의 진행 화면 4개 탭으로 기능 분리 (참석자, AI 제안, 용어사전, 관련회의록)",
"회의 종료 화면 3가지 액션 옵션으로 워크플로우 유연성 확보"
]
},
"functionality": {
"improvements": [
"신규 기능 3개 추가 (참석자 실시간 초대, 실시간 AI 제안, 알림 발송)",
"알림 아키텍처 폴링 방식으로 통일하여 시스템 안정성 확보",
"모든 유저스토리에 입력/출력 명세 추가로 API 설계 가이드 제공",
"예외처리 시나리오 추가로 에러 핸들링 강화",
"관련 유저스토리 명시로 기능 간 연계성 파악 용이"
],
"architectureAlignment": [
"Notification 서비스의 독립성 강화 (폴링 방식)",
"프로토타입 10개 화면과 유저스토리 1:1 매핑",
"WebSocket 기반 실시간 동기화 시나리오 명확화"
]
},
"documentation": {
"improvements": [
"유저스토리 형식 표준화로 일관성 확보",
"AFR/UFR 혼용 제거, UFR로 통일하여 코드 체계 단순화",
"프로토타입 파일명 명시로 개발 시 참조 용이성 향상",
"각 유저스토리에 관련 유저스토리 섹션 추가로 추적성 확보",
"문서 구조 간소화 (논리 아키텍처 섹션 제거)"
],
"qualityEnhancement": [
"v2.2.0: 25개 유저스토리, 자유 형식",
"v2.3.0: 27개 유저스토리, 표준 형식 (수행절차, 입력, 출력/결과, 예외처리, 관련 유저스토리)",
"평균 유저스토리 상세도 약 3배 증가 (기존 20-30줄 → 60-100줄)"
]
}
},
"statistics": {
"v2.2.0": {
"totalUserStories": 25,
"afrCodes": 2,
"ufrCodes": 23,
"averageLinesPerStory": "20-30 (추정)"
},
"v2.3.0": {
"totalUserStories": 27,
"afrCodes": 0,
"ufrCodes": 27,
"averageLinesPerStory": "60-100 (추정)"
},
"changes": {
"added": 5,
"removed": 2,
"modified": "대다수 (프로토타입 기반 재작성)"
}
},
"keyTakeaways": [
"v2.3.0은 프로토타입 분석을 통해 유저스토리를 전면 재정비한 버전",
"신규 기능 3개 추가: 참석자 실시간 초대, 실시간 AI 제안, 알림 발송",
"알림 아키텍처를 폴링 방식으로 통일하여 시스템 안정성 확보",
"유저스토리 형식 표준화로 개발 가이드 역할 강화",
"프로토타입 10개 화면과 유저스토리 1:1 매핑으로 개발 명확성 확보",
"기존 24개 유저스토리 ID 승계하여 연속성 유지",
"평균 유저스토리 상세도 약 3배 증가로 품질 대폭 향상"
],
"recommendedActions": [
"API 설계서를 v2.3.0 유저스토리 기반으로 업데이트 (입력/출력 명세 반영)",
"프로토타입과 유저스토리 간 1:1 매핑 검증",
"신규 추가된 UFR-MEET-015, UFR-AI-030, UFR-NOTI-010 기반 API 및 시퀀스 설계",
"알림 아키텍처 폴링 방식 반영하여 물리 아키텍처 업데이트",
"각 유저스토리의 예외처리 시나리오를 테스트 케이스로 전환",
"관련 유저스토리 섹션을 활용하여 통합 테스트 시나리오 작성"
]
}

View File

@ -0,0 +1,404 @@
# 유저스토리 v2.2.0 → v2.3.0 변경사항 분석 보고서
**분석 일시**: 2025-10-25
**이전 버전**: v2.2.0 (2025-10-23)
**현재 버전**: v2.3.0 (2025-10-24)
**분석자**: Claude (AI Assistant)
---
## 📊 주요 통계
| 항목 | v2.2.0 | v2.3.0 | 변화 |
|------|--------|--------|------|
| **총 유저스토리 수** | 25개 | 27개 | +2개 |
| **신규 추가** | - | 5개 | - |
| **삭제 (AFR → UFR 전환)** | 2개 | - | - |
| **AFR 코드** | 2개 | 0개 | -2개 |
| **UFR 코드** | 23개 | 27개 | +4개 |
| **평균 상세도** | 20-30줄 | 60-100줄 | 약 3배 증가 |
---
## 🏗️ 문서 구조 변경
### v2.2.0 구조
```
1. 차별화 전략
2. 마이크로서비스 구성
3. 유저스토리 (자유 형식)
4. 논리 아키텍처 반영 사항 요약
5. 문서 이력
```
### v2.3.0 구조 (개선)
```
1. 차별화 전략
2. 마이크로서비스 구성
3. 유저스토리 v2.3.0 - USER & MEETING 서비스 (표준 형식)
- 수행절차
- 입력
- 출력/결과
- 예외처리
- 관련 유저스토리
4. 문서 이력
```
### 주요 구조 변경사항
- ✅ **논리 아키텍처 반영 사항 요약 섹션 삭제**: 설계 문서로 이관
- ✅ **유저스토리 형식 표준화**: 모든 유저스토리에 5개 필수 섹션 적용
- ✅ **AFR 코드 폐지**: UFR로 통일하여 코드 체계 단순화
- ✅ **프로토타입 연계 강화**: 화면 번호, 파일명 명시
---
## ✨ 신규 추가 유저스토리 (5개)
### 1. UFR-USER-010: [로그인]
**이전**: AFR-USER-010 (아키텍처 참조 코드)
**변경**: UFR-USER-010 (사용자 기능 요구사항 코드)
**주요 개선사항**:
- 프로토타입 `01-로그인.html` 기반 재작성
- 상세 수행절차 6단계 추가
- 입력 검증 규칙 명시 (비밀번호 최소 8자, Enter 키 자동 이동)
- 예외처리 5가지 추가
- 로그인 상태 유지 체크박스 추가
**관련 프로토타입**: `01-로그인.html`
---
### 2. UFR-USER-020: [대시보드]
**이전**: AFR-USER-020
**변경**: UFR-USER-020
**주요 개선사항**:
- 프로토타입 `02-대시보드.html` 기반 전면 재설계
- 통계 블록 2열 그리드 (예정된 회의, 나의 Todo)
- 최근 회의 목록 (최대 3개, 회의록 미생성 우선)
- 나의 Todo 목록 (최대 3개, 미완료 우선, D-day 표시)
- 나의 회의록 2x2 그리드 (최대 4개)
- FAB 메뉴 (회의예약, 바로시작)
- 반응형 네비게이션 (데스크톱 사이드바, 모바일 하단 탭)
**관련 프로토타입**: `02-대시보드.html`
---
### 3. UFR-MEET-015: [회의진행] 참석자 실시간 초대 🆕
**완전 신규 추가**
**기능 설명**:
- 회의 진행 중 추가 참석자가 필요할 때 실시간으로 초대
- 검색 모달을 통한 사용자 검색 및 선택
- 초대된 참석자 실시간 표시
- Notification 서비스 연동 (초대 알림 발송)
- 모든 참석자에게 WebSocket 기반 실시간 동기화
**의미**:
- 프로토타입 `05-회의진행.html`의 "참석자" 탭 기능 구현
- 회의 진행 중 동적 참석자 관리로 유연성 향상
**관련 유저스토리**:
- UFR-MEET-030 (회의시작)
- UFR-COLLAB-010 (회의록수정동기화)
- UFR-NOTI-010 (알림발송)
**관련 프로토타입**: `05-회의진행.html`
---
### 4. UFR-AI-030: [실시간AI제안] 🆕 🎯
**완전 신규 추가** - **차별화 핵심 기능**
**기능 설명**:
- 회의 진행 중 STT 텍스트 실시간 분석
- AI가 주요 내용 감지 시 제안 카드 자동 생성
- 제안 제목, 내용 (1-2문장), 타임스탬프 표시
- "메모에 추가" 버튼으로 회의 메모에 즉시 반영
- 모든 참석자에게 실시간 동기화
- 로컬 캐시를 통한 네트워크 오류 대응
**의미**:
- 프로토타입 `05-회의진행.html`의 "AI 제안" 탭 핵심 기능
- **차별화 전략의 "지능형 회의 진행 지원" 실현**
- 회의 중 놓치는 내용 최소화로 회의록 품질 향상
**관련 유저스토리**:
- UFR-STT-020 (텍스트변환)
- UFR-MEET-030 (회의시작)
- UFR-COLLAB-010 (회의록수정동기화)
**관련 프로토타입**: `05-회의진행.html`
---
### 5. UFR-NOTI-010: [알림발송] 🆕
**완전 신규 추가** - **알림 아키텍처 정의**
**기능 설명**:
- 주기적 폴링 방식 (1분 간격) 알림 발송
- Notification 테이블에서 발송 대기 알림 조회
- 이메일 발송 실패 시 최대 3회 재시도
- 알림 유형별 템플릿 적용
- 6가지 알림 유형 지원:
- Todo 할당
- Todo 완료
- 회의 시작 (10분 전)
- 회의록 확정
- 참석자 초대
- 회의록 수정
**의미**:
- **알림 아키텍처를 실시간 발송에서 폴링 방식으로 통일**
- Notification 서비스의 독립성과 안정성 확보
- 마이크로서비스 간 느슨한 결합 실현
**관련 유저스토리**:
- UFR-TODO-010 (Todo할당)
- UFR-TODO-030 (Todo완료처리)
- UFR-MEET-015 (참석자 실시간 초대)
- UFR-MEET-050 (최종확정)
---
## ❌ 삭제된 유저스토리 (2개)
### 1. AFR-USER-010: [사용자관리]
**삭제 이유**: UFR-USER-010으로 전환 (AFR 코드 체계 폐지)
**영향**: 기능은 UFR-USER-010으로 승계되어 유지됨
### 2. AFR-USER-020: [대시보드]
**삭제 이유**: UFR-USER-020으로 전환 (프로토타입 기반 재작성)
**영향**: 기능은 강화되어 UFR-USER-020으로 승계됨
---
## 🔄 주요 수정된 유저스토리
### 1. UFR-MEET-010: [회의예약]
**변경사항**:
- 프로토타입 `03-회의예약.html` 기반 전면 재작성
- 상세한 수행절차 10단계 추가
- 입력 필드 상세 명세 (종일 회의 토글, 온라인/오프라인 회의 토글, 회의 링크 자동 생성)
- 예외처리 8가지 추가 (과거 날짜 선택, 뒤로가기 확인 모달 등)
- 임시저장 기능 추가
- 참석자 검색 모달 UI 상세화
**의미**: 프로토타입과의 정확한 매칭으로 개발 시 명확한 가이드 제공
---
### 2. UFR-MEET-020: [템플릿선택]
**변경사항**:
- 프로토타입 `04-템플릿선택.html` 기반 재작성
- 4가지 템플릿 내용 상세 명세:
- 일반 회의: 회의 개요, 논의 사항, 결정 사항, 액션 아이템
- 스크럼 회의: 어제 한 일, 오늘 할 일, 블로커/이슈
- 킥오프 회의: 프로젝트 개요, 목표 및 범위, 역할 및 책임, 일정 및 마일스톤
- 주간 회의: 지난주 성과, 이번주 계획, 주요 이슈, 다음 액션
- 건너뛰기 옵션 추가
- 템플릿 미리보기 구성 명시
**의미**: 템플릿별 섹션 구성이 구체화되어 일관된 회의록 작성 지원
---
### 3. UFR-MEET-030: [회의시작]
**변경사항**:
- 프로토타입 `05-회의진행.html` 기반 전면 재작성
- 8단계 상세 수행절차 추가
- **4개 탭 네비게이션 명시**:
- 참석자: 참석자 목록 및 실시간 초대
- **AI 제안**: 실시간 AI 분석 결과 및 메모 추가 (신규)
- 용어사전: 자동 추출된 용어 및 검색
- 관련회의록: 자동 연결된 이전 회의록
- 웨이브폼 애니메이션, 타이머, 녹음 상태 UI 추가
- 하단 고정 메모 영역 추가
- 일시정지 및 종료 확인 모달 추가
**의미**: 회의 진행 화면의 핵심 UX가 상세히 정의되어 실시간 협업 기능 구현 가이드 제공
---
### 4. UFR-MEET-040: [회의종료]
**변경사항**:
- 프로토타입 `07-회의종료.html` 기반 재작성
- 통계 카드 4개 명시 (참석자, 시간, 안건, Todo)
- 주요 키워드 태그 표시 추가
- 안건별 아코디언 카드 구조 명시:
- AI 한줄 요약 (30자 이내, 편집 불가)
- AI 상세 요약 (편집 가능, 재생성 가능)
- 자동 추출된 Todo 목록
- 읽기 전용 안내 표시
- **하단 액션 바 3가지 옵션**:
- 옵션 1: 회의록 수정 → 회의록 수정 화면으로 이동
- 옵션 2: 바로 최종 확정 → 모든 안건 자동 검증 완료 처리
- 옵션 3: 대시보드 → 대시보드로 이동
**의미**: 회의 종료 후 워크플로우가 명확해져 사용자 선택권 확대 및 UX 개선
---
### 5. UFR-MEET-050: [최종확정]
**변경사항**:
- **2가지 시나리오로 분리**:
- 시나리오 1: 회의록 수정 화면에서 최종 확정
- 시나리오 2: 회의 종료 화면에서 바로 확정
- 각 시나리오별 수행절차 5-6단계 상세화
- 확인 모달 메시지 구체화
- 바로 확정 시 모든 안건 자동 검증 완료 처리 로직 추가
- 확정 후 편집 권한 정책 명시 (회의 생성자만 잠금 해제 후 수정 가능)
**의미**: 유연한 확정 워크플로우 제공으로 사용자 편의성 향상
---
### 6. UFR-MEET-046: [회의록목록조회]
**변경사항**:
- 프로토타입 `12-회의록목록조회.html` 기반 재작성
- **데이터 소스 명시**: `common.js``SAMPLE_MINUTES` 배열 (30개 샘플 데이터)
- 필터링 옵션 상세화:
- 상태별: 전체 / 작성중 / 확정완료
- 정렬: 최근수정순 / 최근회의순 / 제목순
- 참여 유형: 참석한 회의 / 생성한 회의
- 검색: 회의 제목, 참석자, 키워드
- 통계 표시 추가
- 페이지네이션 방식 명시 (초기 10개, "10개 더보기" 버튼)
- 목록 표시 정보 8가지 추가
- **우선순위 변경**: M (Must) → S (Should) - MVP 집중
**의미**: 프로토타입 연계 강화, 샘플 데이터 30개 기반 개발 가능
---
### 7. UFR-MEET-047: [회의록상세조회]
**변경사항**:
- 프로토타입 `10-회의록상세조회.html` 기반 재작성
- 회의 기본 정보 표시 항목 7가지 상세화
- 섹션별 상세 내용 표시 구조 추가
- **관련 회의록 섹션 추가** (최대 3개, 관련도 % 표시)
- 탭 네비게이션 구성 명시 (대시보드, 회의록 2개 탭)
**의미**: 회의록 조회 화면 정보 구조 명확화 및 관련 회의록 연결 강화
---
### 8. UFR-MEET-055: [회의록수정]
**변경사항**:
- 프로토타입 `11-회의록수정.html` 기반 전면 재작성
- **3가지 시나리오로 확장**:
- 시나리오 1: 작성중 회의록 수정
- 시나리오 2: 확정완료 회의록 수정 (잠금 해제 필요)
- 시나리오 3: 안건 검증
- 각 시나리오별 상세 수행절차 추가
- 잠금 해제 메커니즘 명시 (확정완료 회의록의 경우)
- 검증 완료 프로세스 상세화 (안건별 체크 버튼, 검증률 표시)
**의미**: 회의록 수정 워크플로우가 상태별로 명확해져 협업 시나리오 지원 강화
---
## 🎯 전체 영향 분석
### 1. 사용자 경험 (UX) 개선
#### 주요 개선사항
- ✅ **프로토타입 기반 유저스토리**로 실제 사용 흐름과 정확히 일치
- ✅ **상세한 수행절차**로 사용자 작업 흐름 명확화
- ✅ **예외처리 시나리오 추가**로 에러 상황 대응 개선
- ✅ **실시간 AI 제안 기능**으로 회의 중 놓치는 내용 최소화 (차별화)
- ✅ **유연한 확정 워크플로우**로 사용자 선택권 확대
- ✅ **회의 진행 중 참석자 실시간 초대**로 협업 유연성 향상
#### 핵심 UX 강화
- **대시보드 재설계**: 통계 블록, 최근 회의, Todo, 회의록 4개 섹션으로 정보 접근성 향상
- **회의 진행 화면 4개 탭**: 참석자, AI 제안, 용어사전, 관련회의록으로 기능 분리
- **회의 종료 화면 3가지 액션 옵션**: 회의록 수정, 바로 최종 확정, 대시보드로 워크플로우 유연성 확보
---
### 2. 기능성 (Functionality) 개선
#### 신규 기능
1. **UFR-MEET-015**: 회의 진행 중 참석자 실시간 초대
2. **UFR-AI-030**: 실시간 AI 제안 (차별화 핵심)
3. **UFR-NOTI-010**: 알림 발송 (폴링 방식)
#### 아키텍처 정렬
- **알림 아키텍처 폴링 방식으로 통일**: 실시간 발송 → 주기적 폴링 (1분 간격)
- **Notification 서비스 독립성 강화**: 마이크로서비스 간 느슨한 결합
- **프로토타입 10개 화면과 유저스토리 1:1 매핑**: 개발 명확성 확보
- **WebSocket 기반 실시간 동기화 시나리오 명확화**: 협업 기능 강화
#### API 설계 가이드 제공
- 모든 유저스토리에 **입력/출력 명세** 추가
- **예외처리 시나리오** 추가로 에러 핸들링 강화
- **관련 유저스토리** 명시로 기능 간 연계성 파악 용이
---
### 3. 문서화 (Documentation) 개선
#### 표준화 및 일관성
- ✅ **유저스토리 형식 표준화**: 5개 필수 섹션 (수행절차, 입력, 출력/결과, 예외처리, 관련 유저스토리)
- ✅ **AFR/UFR 혼용 제거**: UFR로 통일하여 코드 체계 단순화
- ✅ **프로토타입 파일명 명시**: 개발 시 참조 용이성 향상
- ✅ **관련 유저스토리 섹션 추가**: 추적성 확보
- ✅ **문서 구조 간소화**: 논리 아키텍처 섹션 제거 (설계 문서로 이관)
#### 품질 향상
| 지표 | v2.2.0 | v2.3.0 | 개선율 |
|------|--------|--------|--------|
| 유저스토리 수 | 25개 | 27개 | +8% |
| 평균 상세도 | 20-30줄 | 60-100줄 | **약 3배** |
| 코드 체계 통일 | AFR/UFR 혼용 | UFR로 통일 | 100% 통일 |
| 프로토타입 연계 | 부분적 | 1:1 매핑 | 100% 매핑 |
---
## 💡 핵심 시사점 (Key Takeaways)
1. **v2.3.0은 프로토타입 분석을 통해 유저스토리를 전면 재정비한 버전**
2. **신규 기능 3개 추가**: 참석자 실시간 초대, 실시간 AI 제안, 알림 발송
3. **알림 아키텍처를 폴링 방식으로 통일**하여 시스템 안정성 확보
4. **유저스토리 형식 표준화**로 개발 가이드 역할 강화
5. **프로토타입 10개 화면과 유저스토리 1:1 매핑**으로 개발 명확성 확보
6. **기존 24개 유저스토리 ID 승계**하여 연속성 유지
7. **평균 유저스토리 상세도 약 3배 증가**로 품질 대폭 향상
---
## 📋 권장 후속 조치 (Recommended Actions)
### 1. 설계 문서 업데이트
- [ ] **API 설계서**를 v2.3.0 유저스토리 기반으로 업데이트 (입력/출력 명세 반영)
- [ ] 신규 추가된 **UFR-MEET-015, UFR-AI-030, UFR-NOTI-010** 기반 API 및 시퀀스 설계
- [ ] **알림 아키텍처 폴링 방식** 반영하여 물리 아키텍처 업데이트
### 2. 프로토타입 검증
- [ ] 프로토타입과 유저스토리 간 **1:1 매핑 검증**
- [ ] 프로토타입 화면별 유저스토리 커버리지 확인
### 3. 테스트 계획
- [ ] 각 유저스토리의 **예외처리 시나리오를 테스트 케이스로 전환**
- [ ] **관련 유저스토리 섹션**을 활용하여 통합 테스트 시나리오 작성
- [ ] 신규 기능 3개에 대한 우선 테스트 계획 수립
### 4. 개발 가이드
- [ ] 유저스토리별 개발 우선순위 재평가
- [ ] 신규 기능 3개 개발 일정 수립
- [ ] 프로토타입 기반 개발 가이드 작성
---
## 📎 참조 파일
- **v2.2.0**: `C:\Users\yabo0\home\workspace\HGZero\design\userstory_v2.2.0_backup.md`
- **v2.3.0**: `C:\Users\yabo0\home\workspace\HGZero\design\userstory.md`
- **상세 분석 (JSON)**: `C:\Users\yabo0\home\workspace\HGZero\claude\userstory-comparison-v2.2.0-to-v2.3.0.json`
---
**분석 완료** ✅

View File

@ -0,0 +1,53 @@
# 유저스토리 M/S/C 및 기능점수 분석
## 분석 대상
파일: design/userstory.md (v2.1.2)
총 요구사항: 25개
## M/S/C 및 기능점수 현황
| Line | 요구사항 ID | M/S/C | 점수 | 서비스 | 기능 |
|------|-----------|-------|------|--------|------|
| 75 | AFR-USER-010 | M | 8 | User | 사용자 인증 |
| 135 | AFR-USER-020 | M | 8 | User | 대시보드 |
| 157 | UFR-MEET-010 | M | 13 | Meeting | 회의예약 |
| 179 | UFR-MEET-020 | S | 5 | Meeting | 템플릿선택 |
| 200 | UFR-MEET-030 | M | 8 | Meeting | 회의시작 |
| 250 | UFR-MEET-040 | M | 8 | Meeting | 회의종료 |
| 316 | UFR-MEET-050 | M | 13 | Meeting | 최종확정 |
| 357 | UFR-MEET-046 | M | 8 | Meeting | 회의록목록조회 |
| 423 | UFR-MEET-047 | M | 8 | Meeting | 회의록상세조회 |
| 473 | UFR-MEET-055 | M | 13 | Meeting | 회의록수정 |
| 508 | UFR-STT-010 | M | 21 | STT | 음성녹음인식 |
| 536 | UFR-STT-020 | M | 13 | STT | 텍스트변환 |
| 594 | UFR-AI-010 | M | 34 | AI | 회의록자동작성 |
| 643 | UFR-AI-020 | M | 21 | AI | Todo자동추출 |
| 683 | UFR-AI-035 | M | 21 | AI | 섹션AI요약 |
| 718 | UFR-AI-040 | M | 21 | AI | 관련회의록연결 |
| 756 | UFR-AI-050 | S | 13 | AI | 용어설명 |
| 796 | UFR-AI-060 | S | 13 | AI | 회의록검색 |
| 844 | UFR-AI-070 | S | 21 | AI | 회의패턴분석 |
| 889 | UFR-COLLAB-010 | M | 34 | Meeting | 실시간협업 |
| 945 | UFR-COLLAB-020 | M | 21 | Meeting | 충돌방지 |
| 988 | UFR-TODO-010 | M | 8 | Meeting | Todo관리 |
| 1045 | UFR-TODO-020 | M | 13 | Meeting | Todo연결 |
| 1084 | UFR-NOTI-010 | M | 8 | Notification | 알림발송 |
| 1170 | NFR-PERF-010 | M | 13 | 전체 | 성능요구사항 |
## 통계
- Must (M): 20개 (80%)
- Should (S): 5개 (20%)
- Could (C): 0개 (0%)
- 평균 기능점수: 15.48점
- 최고점: 34점 (UFR-AI-010, UFR-COLLAB-010)
- 최저점: 5점 (UFR-MEET-020)
## 서비스별 분포
- User: 2개 (M:2)
- Meeting: 11개 (M:9, S:0)
- STT: 2개 (M:2)
- AI: 7개 (M:4, S:3)
- Notification: 1개 (M:1)
- NFR: 1개 (M:1)
- 실시간협업/Todo: Meeting 서비스에 통합됨

220
claude/userstory-review.md Normal file
View File

@ -0,0 +1,220 @@
# 유저스토리 M/S/C 및 기능점수 검토 결과
## 검토자: 민준(PO), 서연(AI), 준호(Backend), 유진(Frontend), 도현(QA), 지수(Designer)
---
## 1. M/S/C 우선순위 검토
### ✅ 적절한 Must (M) 항목들
1. **AFR-USER-010** (사용자 인증, M/8) - 핵심 보안 기능
2. **UFR-MEET-010** (회의예약, M/13) - 서비스 핵심 플로우
3. **UFR-MEET-030** (회의시작, M/8) - 서비스 핵심 플로우
4. **UFR-MEET-040** (회의종료, M/8) - 서비스 핵심 플로우
5. **UFR-MEET-050** (최종확정, M/13) - 회의록 완성 필수
6. **UFR-STT-010** (음성녹음인식, M/21) - 기본 기능이지만 필수
7. **UFR-AI-010** (회의록자동작성, M/34) - 핵심 차별화
8. **UFR-COLLAB-010** (실시간협업, M/34) - 핵심 차별화
### ⚠️ M → S로 변경 제안
**UFR-MEET-046** (회의록목록조회, M/8 → **S/8**)
- **이유**: 대시보드에서 최근 회의록을 볼 수 있으므로 1차 출시에서는 목록 조회가 없어도 서비스 가능
- **민준(PO)**: 사용자들이 과거 회의록을 찾기 위해서는 필요하지만, MVP에서는 대시보드만으로도 가능
- **유진(Frontend)**: 1차에서는 대시보드의 "최근 회의" 섹션으로 충분, 필터/검색은 2차 출시 추가 가능
**UFR-MEET-047** (회의록상세조회, M/8 → **S/8**)
- **이유**: 대시보드에서 회의록을 바로 열 수 있으므로, 별도 상세조회 화면은 2차 출시 가능
- **민준(PO)**: 상세조회는 있으면 좋지만, 대시보드 → 수정 화면으로 바로 진입 가능하면 MVP 가능
**AFR-USER-020** (대시보드, M/8 → **유지 M/8**)
- **검토**: 대시보드는 사용자 경험의 시작점이므로 Must 유지 필요
- **지수(Designer)**: 대시보드는 사용자 첫 인상과 전체 서비스 파악에 핵심적
### ⚠️ S → M으로 변경 제안
**UFR-AI-050** (용어설명, S/13 → **M/13**)
- **이유**: 차별화 전략 문서에서 "맥락 기반 용어 설명"을 핵심 차별화 포인트로 명시
- **서연(AI)**: 이 기능이 경쟁사와의 핵심 차별점이므로 1차 출시에 포함해야 함
- **민준(PO)**: 차별화 전략과 일관성을 위해 Must로 승격 필요
**UFR-AI-060** (회의록검색, S/13 → **유지 S/13**)
- **검토**: RAG 기반 검색은 고도화 기능으로 2차 출시 적절
- **서연(AI)**: 벡터 DB 구축 시간 고려 시 2차 출시가 현실적
### ⚠️ Should(S) 추가 제안
**UFR-TODO-020** (Todo연결, M/13 → **S/13**)
- **이유**: Todo 기본 관리(UFR-TODO-010)만 있어도 서비스 가능, 양방향 연결은 고도화 기능
- **준호(Backend)**: Todo-회의록 양방향 연결 복잡도가 높아 2차 출시 권장
- **민준(PO)**: 차별화 전략에 "강화된 Todo 연결"이 있지만, 기본 Todo 관리로도 차별화 가능
---
## 2. 기능점수 검토
### ⚠️ 점수 조정 제안
#### **UFR-AI-035** (섹션AI요약, M/21 → **M/13**)
**현재 점수**: 21
**조정 점수**: 13
**이유**:
- 복잡도: 단일 섹션 요약은 비교적 단순 (LLM 단일 호출)
- UFR-AI-010(회의록자동작성)의 부분 기능으로 기술 재사용 가능
- 처리 시간: 2-5초로 명시되어 있어 기술적 난이도 낮음
**서연(AI)**: 프롬프트 엔지니어링 재사용으로 8-13점 정도가 적절
#### **UFR-AI-040** (관련회의록연결, M/21 → **M/13**)
**현재 점수**: 21
**조정 점수**: 13
**이유**:
- 복잡도: 벡터 유사도 검색 표준 기술 사용
- UFR-AI-060(회의록검색)과 기술 스택 공유
- RAG 인프라 구축 시 함께 개발 가능
**서연(AI)**: 벡터 DB 구축되면 단순 유사도 검색이므로 13점 적절
#### **UFR-STT-010** (음성녹음인식, M/21 → **M/13**)
**현재 점수**: 21
**조정 점수**: 13
**이유**:
- 기본 기능으로 Azure Speech 등 외부 API 사용
- 화자 식별 없이 단순 텍스트 변환만
- 기술 리스크 낮음 (검증된 외부 서비스)
**준호(Backend)**: Azure Speech SDK 연동은 복잡도 낮아 13점 적절
#### **UFR-MEET-010** (회의예약, M/13 → **M/8**)
**현재 점수**: 13
**조정 점수**: 8
**이유**:
- 복잡도: 기본 CRUD + 이메일 발송
- 알림 서비스(UFR-NOTI-010, M/8)에 의존하지만 단순 호출
- 캘린더 연동은 나중 고도화 가능
**준호(Backend)**: 기본 예약 기능은 8점, 캘린더 자동 등록 제외 시 더 낮아질 수 있음
#### **UFR-TODO-020** (Todo연결, M/13 → **S/13 유지**)
**현재 점수**: 13
**조정 후**: S/13
**이유**:
- 복잡도: Todo-회의록 양방향 동기화는 고도화 기능
- UFR-TODO-010 (기본 Todo 관리)로도 서비스 가능
**준호(Backend)**: 양방향 연결은 복잡도 13점 적절, Should로 분류 권장
#### **UFR-COLLAB-010** (실시간협업, M/34) - **점수 유지**
**검토 결과**: 34점 적절
**이유**:
- WebSocket 인프라 + 버전 관리 + 충돌 해결
- 기술 복잡도 매우 높음
- 다수 동시 접속 처리 필요
**준호(Backend)**: WebSocket 서버 + Redis 캐시 + 버전 관리 로직으로 34점 타당
#### **UFR-AI-010** (회의록자동작성, M/34) - **점수 유지**
**검토 결과**: 34점 적절
**이유**:
- 실시간 처리 + 회의 종료 시 전체 요약 (2단계)
- 템플릿 반영 + 주요 메모 통합
- LLM 프롬프트 엔지니어링 복잡도 높음
**서연(AI)**: 실시간/배치 2단계 처리로 34점 타당
---
## 3. 종합 개선 제안
### M/S/C 변경 요약
| 요구사항 ID | 현재 | 제안 | 변경 이유 |
|-----------|------|------|----------|
| UFR-MEET-046 | M/8 | S/8 | 대시보드로 대체 가능 |
| UFR-MEET-047 | M/8 | S/8 | 대시보드 → 수정 바로 진입 가능 |
| UFR-AI-050 | S/13 | M/13 | 차별화 전략 핵심 |
| UFR-TODO-020 | M/13 | S/13 | 기본 Todo로 충분 |
### 기능점수 변경 요약
| 요구사항 ID | 현재 | 제안 | 변경 이유 |
|-----------|------|------|----------|
| UFR-AI-035 | M/21 | M/13 | 기술 재사용으로 복잡도 낮음 |
| UFR-AI-040 | M/21 | M/13 | 표준 벡터 검색 기술 |
| UFR-STT-010 | M/21 | M/13 | 외부 API 사용으로 리스크 낮음 |
| UFR-MEET-010 | M/13 | M/8 | 기본 CRUD 수준 |
### 변경 후 통계
**M/S/C 분포**:
- Must (M): 18개 (72%) ← 기존 20개
- Should (S): 7개 (28%) ← 기존 5개
- Could (C): 0개 (0%)
**평균 기능점수**:
- 변경 전: 15.48점
- 변경 후: 14.00점 (약 10% 감소)
**총 기능점수**:
- 변경 전: 387점
- 변경 후: 350점
---
## 4. MVP 출시 범위 권장사항
### 1차 출시 (Must만)
- 사용자 인증 및 대시보드
- 회의 예약/시작/종료/확정
- 음성 인식 및 회의록 자동 작성
- Todo 자동 추출 및 기본 관리
- 섹션 AI 요약 재생성
- **용어 설명 (차별화)**
- 관련 회의록 자동 연결
- 실시간 협업 및 충돌 방지
- 알림 발송
총 기능점수: **292점** (변경 후)
### 2차 출시 (Should 추가)
- 회의록 목록 조회 및 필터링
- 회의록 상세 조회 전용 화면
- 템플릿 선택 및 커스터마이징
- 회의록 RAG 검색
- 회의 패턴 분석 및 추천
- Todo 양방향 연결 강화
총 추가 기능점수: **58점**
---
## 5. 리스크 및 제약사항
### 기술 리스크
1. **UFR-COLLAB-010** (실시간협업, 34점)
- WebSocket 동시 접속 부하 테스트 필수
- Redis 캐시 장애 시 대응 방안 필요
2. **UFR-AI-010** (회의록자동작성, 34점)
- LLM 응답 시간 변동성 관리
- 프롬프트 품질 검증 시간 필요
3. **UFR-AI-050** (용어설명, M/13)
- RAG 인프라 구축 리드타임 (벡터 DB, 임베딩)
- 사내 문서 수집 및 전처리 시간
### 일정 제약
- 변경 후 총 기능점수: 350점
- 1인당 월 평균 생산성: 30-40점 (경험치)
- 4명 개발팀 기준: **약 2.5개월** 소요 예상
---
## 6. 최종 권고사항
### 지수(Designer)
- UFR-MEET-046, UFR-MEET-047을 Should로 변경하되, 사용자 경험 테스트 후 재검토 권장
- 대시보드만으로 충분한지 프로토타입 단계에서 검증 필요
### 서연(AI)
- UFR-AI-050(용어설명)은 차별화 전략 핵심이므로 Must로 승격 강력 권장
- UFR-AI-035, UFR-AI-040의 점수 하향은 기술 재사용 관점에서 타당
### 준호(Backend)
- UFR-TODO-020을 Should로 변경하여 개발 복잡도 분산 권장
- UFR-COLLAB-010의 기술 리스크 대비 시간 확보 필요
### 도현(QA)
- Must 항목 18개로 축소하면 테스트 범위 집중 가능
- 실시간 협업(UFR-COLLAB-010) 부하 테스트 충분한 시간 필요
### 민준(PO)
- 차별화 전략과 일관성 유지를 위해 UFR-AI-050을 Must로 승격 승인
- MVP 범위를 명확히 하여 1차 출시 집중, 2차 출시 계획 수립

154
claude/userstory-writing.md Normal file
View File

@ -0,0 +1,154 @@
# 유저스토리 작성 방법
## 개요
이 가이드는 마이크로서비스 기반 시스템 개발을 위한 유저스토리 작성 표준을 제공합니다.
표준화된 형식을 통해 일관성 있고 완전한 요구사항을 정의할 수 있습니다.
## 작성 구성 요소
### 1. 서비스
마이크로서비스명을 명시합니다.
- **형식**: 서비스 도메인명
- **예시**: 홈페이지, 가입설계, 주문관리, 결제처리
### 2. ID
User Story ID로서 표준화된 식별자입니다.
- **형식**: `<유저스토리 유형 코드>-<서비스약어>-<일련번호>`
- 유저스토리 유형 코드
- UFR(User Functional Requirements): 사용자 기능 요구사항
- AFR(Admin Functional Requirements): 어드민 기능 요구사항
- NFR(Non Functiional Requirements): 비기능 요구사항(확장성, 회복성, 유연성, 성능, 보안, 운영성)
- 서비스약어: 3~4자로 작성
- 일련번호: 3자리로 하고 010부터 시작하여 10개씩 증가 예) UFR-HOME-010, UFR-HOME-020
- **예시**:
- `UFR-HOME-010`: 홈페이지 서비스의 첫 번째 유저스토리
- `UFR-PAY-020`: 결제 서비스의 다섯 번째 유저스토리
### 3. Epic
유저스토리의 상위 카테고리를 분류합니다.
- **용도**: 관련 유저스토리들을 그룹화
- **예시**: 사용자 관리, 상품 관리, 주문 처리
### 4. 유저스토리
표준 형식에 따라 작성합니다. 각 파트는 파이프로 구분합니다.
- **형식**: `[유저스토리 제목] <유저유형>으로서 | 나는, <비즈니스 목적>을 위해 | <작업/기능>을(를) 원합니다.`
- **예시**:
- [상품검색] 쇼핑몰 고객으로서 | 나는, 상품을 쉽게 찾기 위해 | 카테고리별 상품 검색 기능을 원합니다.
- [주문현황] 관리자로서 | 나는, 주문 상태를 파악하기 위해 | 실시간 주문 현황 대시보드를 원합니다.
중요) 유저유형은 사람 뿐 아니라 시스템, API 등으로 정의할 수도 있음
- 이벤트 스토밍은 사용자 중심으로 수행하기 위해 사람만 Actor로 허용
- 유저스토리는 충분한 요구사항 전달을 위해 사람이 아닌 유저유형도 허용함
### 5. Biz중요도 (MoSCoW 분류)
우선순위에 따른 분류입니다.
| 분류 | 의미 | 설명 |
|------|------|------|
| **M (Must)** | 반드시 필요 | 핵심 비즈니스 기능, 없으면 서비스 불가능 |
| **S (Should)** | 매우 필요하나 대체할 방법은 있음 | 중요하지만 우회 방법이 존재 |
| **C (Could)** | 있으면 좋으나 우선 순위는 떨어짐 (Nice to have) | 사용자 편의성 향상 기능 |
| **W (Won't)** | 가장 우선순위가 떨어지므로 보류해도 됨 | 향후 개발 고려 기능 |
### 6. 인수테스트 시나리오
기능 완성도를 검증하기 위한 테스트 시나리오입니다.
#### 시나리오명
- 테스트할 기능이나 상황을 명확히 표현
- **예시**: "정상적인 회원 가입 처리", "중복 이메일 가입 시도"
#### 인수기준 (Given-When-Then 형식)
- **형식**: `<Given> | <When> | <Then>`
- **Given (사전 조건/상황)**: 테스트 실행 전 준비사항
- **When (Action)**: 사용자가 수행하는 액션
- **Then (결과)**: 기대되는 결과 또는 시스템 반응
- **예시**
```
미 로그인 상태로 서비스에 접근하여 | ID와 암호를 입력하여 로그인 요청을 하면 | 대시보드 페이지가 표시된다.
```
#### 체크리스트
세부 테스트 항목을 최대한 자세히 작성합니다.
- 기능/비기능 요구사항 검증 항목
- 예외 상황 처리 검증
- 통합 테스트 항목
### 7. Score
구현 난이도를 피보나치 수열을 이용하여 표현합니다.
- **수열**: 1, 2, 3, 5, 8, 13, 21, 34, 55, 89...
- **기준**:
| 점수 | 난이도 | 설명 | 세부 기준 | 예시 |
|------|--------|------|-----------|------|
| **1-2** | **매우 간단** | 기본적인 CRUD 작업 | • 단일 파일 수정<br>• 간단한 설정 변경<br>• 단순 명령어 실행 | • README 작성<br>• 간단한 스크립트 실행<br>• 환경변수 설정 |
| **3-5** | **간단** | 기본 비즈니스 로직 포함 | • 여러 파일 수정<br>• 간단한 테스트 작성<br>• 기본 에러 처리 | • 설정 파일 파싱<br>• 간단한 데이터 변환<br>• 기본 유효성 검사 |
| **8-13** | **보통** | 복잡한 비즈니스 로직 | • API 연동<br>• 데이터베이스 처리<br>• 복잡한 알고리즘<br>• 외부 도구 통합 | • MCP 서버 연동<br>• 파일 시스템 조작<br>• CLI 인터페이스 개발 |
| **21-34** | **복잡** | 다중 시스템 연동 | • 여러 서비스 통합<br>• 복잡한 상태 관리<br>• 성능 최적화 필요<br>• 보안 고려사항 | • GitHub API 통합<br>• 실시간 모니터링<br>• 복잡한 워크플로우 |
| **55+** | **매우 복잡** | 새로운 기술/패러다임 | • 신규 아키텍처 설계<br>• 혁신적 기능 개발<br>• 대규모 리팩토링<br>• 연구개발 요소 | • 새로운 플러그인 아키텍처<br>• AI 모델 통합<br>• 분산 시스템 설계 |
## 결과 형식
- 코드블록 내에 작성함
- 구성
```
{서비스 일련번호}. {서비스명}
{Epic 일련번호}. {Epic}
{유저스토리 ID}: [{유저스토리 제목}]: {유저스토리}
- 시나리오: {시나리오}
{인수기준}
- {체크 리스트}
- {Biz중요도}/{Score}
```
작성예시
```
1. User 서비스
1) 사용자 인증 및 관리
RQ-USER-010: [회원가입] 사용자로서 | 나는, 여행 계획을 관리하기 위해 | 간편하게 회원가입하고 싶다.
- 시나리오: 회원가입
미 로그인 상태로 서비스에 접근한 상황에서 | 사용자 기본정보(이름, 이메일, 연락처), ID, 암호를 입력하여 회원가입 요청하면 | 회원가입이 된다.
- [ ] 이름, 이메일, 연락처 등록 체크
- [ ] ID는 5자 이상의 영숫자
- [ ] 암호는 8자 이상의 영숫자와 특수문자가 최소 1개 이상 포함
- M/5
```
## 추가 항목
추가 항목은 필요 시 추가 가능합니다.
예를 들어 '기술 태스크'와 같은 기술적 내용을 추가할 수 있습니다.
예시)
```
UFR-AI-010: [AI일정생성] 여행자로서 | 나는 맞춤형 여행 일정을 받기 위해 | AI가 내 여행 정보와 이동수단 선호도를 기반으로 최적화된 일정을 생성하기를 원한다.
- 시나리오: AI 일정 생성 결과 확인
여행 기본정보와 여행지를 설정하고 AI 일정 생성을 요청한 상황에서 | 5초 이내에 생성이 완료되면 | 선호 이동수단을 기반으로 한 시간대별 상세 일정이 생성되어 확인할 수 있다.
[생성 결과 검증]
- 모든 여행지에 대한 일정 존재
- 각 일자별 시작/종료 시간 일치
- ...
- M/8
- 기술 태스크
- AI 서비스 API 구현
- POST /ai/schedules/generate (일정 생성 요청)
- GET /ai/schedules/{id}/status (진행 상태 조회)
- GET /ai/schedules/{id} (생성된 일정 조회)
- AI 모델 통합
- Claude API 연동
- 프롬프트 엔지니어링
- 응답 파싱 및 구조화
```
## 참고 자료
- [유저스토리 작성 샘플](https://raw.githubusercontent.com/cna-bootcamp/clauding-guide/refs/heads/main/samples/Userstory.pdf)
## 결과 파일
작성된 유저스토리는 다음 위치에 저장됩니다:
- **파일 경로**: `design/Userstory.md`
- **형식**: 마크다운 형식으로 모든 유저스토리를 포함
## 작성 시 주의사항
1. **명확성**: 모호한 표현 대신 구체적이고 측정 가능한 표현 사용
2. **완전성**: 모든 필수 구성 요소를 빠짐없이 작성
3. **추적성**: ID를 통해 설계 문서와 연결 가능하도록 작성
4. **테스트 가능성**: 인수테스트 시나리오가 실제 테스트로 실행 가능하도록 구체적으로 작성
5. **우선순위**: MoSCoW 분류를 통해 개발 우선순위 명확화

25
claude/v220_codes.txt Normal file
View File

@ -0,0 +1,25 @@
AFR-USER-010: [사용자관리] 시스템 관리자로서 | 나는, 서비스 보안을 위해 | 사용자 인증 기능을 원한다.
AFR-USER-020: [대시보드] 사용자로서 | 나는, 회의록 서비스의 주요 정보를 한눈에 파악하기 위해 | 대시보드를 통해 요약 정보를 확인하고 싶다.
UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다.
UFR-MEET-020: [템플릿선택] 회의 생성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다.
UFR-MEET-030: [회의시작] 회의 생성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다.
UFR-MEET-040: [회의종료] 회의 생성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 요약 내용을 확인한 후 다음 단계를 선택하고 싶다.
UFR-MEET-050: [최종확정] 회의 생성자로서 | 나는, 회의록을 완성하기 위해 | 모든 안건을 검증하고 최종 회의록을 확정하고 싶다.
UFR-MEET-046: [회의록목록조회] 회의 참석자로서 | 나는, 참여한 회의록들을 효율적으로 관리하기 위해 | 회의록 목록을 조회하고 필터링하고 싶다.
UFR-MEET-047: [회의록상세조회] 회의 참석자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다.
UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 수정하고 싶다.
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
UFR-STT-020: [텍스트변환] 회의록 시스템으로서 | 나는, 인식된 발언을 회의록에 기록하기 위해 | 음성을 텍스트로 변환하고 싶다.
UFR-AI-010: [회의록자동작성] 회의 참석자로서 | 나는, 회의록 작성 부담을 줄이기 위해 | AI가 발언 내용을 실시간으로 정리하고 회의 종료 시 전체 안건을 요약하기를 원한다.
UFR-AI-020: [Todo자동추출] 회의 참석자로서 | 나는, 회의 후 실행 사항을 명확히 하기 위해 | AI가 안건별 내용에서 Todo 항목을 자동으로 추출하고 기본값을 설정하기를 원한다.
UFR-AI-035: [섹션AI요약] 회의 참석자로서 | 나는, 작성한 섹션 내용을 쉽게 요약하기 위해 | 버튼 클릭으로 AI가 섹션 내용을 요약해주기를 원한다.
UFR-AI-036: [AI한줄요약] 회의 참석자로서 | 나는, 각 안건의 핵심을 빠르게 파악하기 위해 | AI가 생성한 편집 불가능한 한줄 요약을 확인하고 싶다.
UFR-AI-040: [관련회의록연결] 회의 참석자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
UFR-RAG-010: [전문용어감지] 회의 참석자로서 | 나는, 업무 지식이 없어도 회의록을 정확히 작성하기 위해 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명을 제공받고 싶다.
UFR-RAG-020: [맥락기반용어설명] 회의 참석자로서 | 나는, 전문용어를 맥락에 맞게 이해하기 위해 | 관련 회의록과 업무 이력을 바탕으로 실용적인 설명을 제공받고 싶다.
UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회의록을 함께 검증하기 위해 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다.
UFR-COLLAB-020: [충돌해결] 회의 참석자로서 | 나는, 동시 수정 상황에서도 내용을 잃지 않기 위해 | 안건별로 충돌 없이 편집하고 싶다.
UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정확성을 보장하기 위해 | 각 안건을 검증하고 완료 표시를 하고 싶다.
UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다.
UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
UFR-TODO-040: [Todo관리] Todo 담당자로서 | 나는, 나의 Todo를 효율적으로 관리하기 위해 | Todo 목록을 조회하고 상태를 변경하고 편집하고 싶다.

28
claude/v230_codes.txt Normal file
View File

@ -0,0 +1,28 @@
UFR-USER-010: [로그인] 사용자로서 | 나는, 시스템에 접근하기 위해 | 사번과 비밀번호로 로그인하고 싶다.
UFR-USER-020: [대시보드] 사용자로서 | 나는, 나의 회의 및 Todo 현황을 파악하기 위해 | 대시보드를 조회하고 싶다.
UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, 회의를 효율적으로 준비하기 위해 | 회의를 예약하고 참석자를 초대하고 싶다.
UFR-MEET-015: [회의진행] 회의 참석자로서 | 나는, 회의 중 추가 참석자가 필요할 때 | 실시간으로 참석자를 초대하고 싶다.
UFR-MEET-020: [템플릿선택] 회의 생성자로서 | 나는, 회의록을 효율적으로 작성하기 위해 | 회의 유형에 맞는 템플릿을 선택하고 싶다.
UFR-MEET-030: [회의시작] 회의 생성자로서 | 나는, 회의를 시작하고 회의록을 작성하기 위해 | 회의를 시작하고 음성 녹음을 준비하고 싶다.
UFR-MEET-040: [회의종료] 회의 생성자로서 | 나는, 회의를 종료하고 회의록을 정리하기 위해 | 회의를 종료하고 요약 내용을 확인한 후 다음 단계를 선택하고 싶다.
UFR-MEET-050: [최종확정] 회의 생성자로서 | 나는, 회의록을 완성하기 위해 | 모든 안건을 검증하고 최종 회의록을 확정하고 싶다.
UFR-MEET-046: [회의록목록조회] 회의 참석자로서 | 나는, 참여한 회의록들을 효율적으로 관리하기 위해 | 회의록 목록을 조회하고 필터링하고 싶다.
UFR-MEET-047: [회의록상세조회] 회의 참석자로서 | 나는, 지난 회의록의 상세 정보와 전체 내용을 | 한눈에 확인하고 싶다.
UFR-MEET-055: [회의록수정] 회의 참석자로서 | 나는, 검증이 완료되지 않았거나 수정이 필요한 | 지난 회의록을 수정하고 싶다.
UFR-AI-010: [회의록자동작성] 회의 참석자로서 | 나는, 회의록 작성 부담을 줄이기 위해 | AI가 발언 내용을 실시간으로 정리하고 회의 종료 시 전체 안건을 요약하기를 원한다.
UFR-AI-020: [Todo자동추출] 회의 참석자로서 | 나는, 회의 후 실행 사항을 명확히 하기 위해 | AI가 안건별 내용에서 Todo 항목을 자동으로 추출하고 기본값을 설정하기를 원한다.
UFR-AI-030: [실시간AI제안] 회의 참석자로서 | 나는, 회의 중 놓치는 내용을 최소화하기 위해 | AI가 실시간으로 주요 내용을 분석하여 제안하고 싶다.
UFR-AI-035: [섹션AI요약] 회의 참석자로서 | 나는, 작성한 섹션 내용을 쉽게 요약하기 위해 | 버튼 클릭으로 AI가 섹션 내용을 요약해주기를 원한다.
UFR-AI-036: [AI한줄요약] 회의 참석자로서 | 나는, 각 안건의 핵심을 빠르게 파악하기 위해 | AI가 생성한 편집 불가능한 한줄 요약을 확인하고 싶다.
UFR-AI-040: [관련회의록연결] 회의 참석자로서 | 나는, 이전 회의 내용을 쉽게 참조하기 위해 | AI가 같은 폴더 내 관련 있는 과거 회의록을 자동으로 찾아 연결해주기를 원한다.
UFR-STT-010: [음성녹음인식] 회의 참석자로서 | 나는, 발언 내용이 자동으로 기록되기 위해 | 음성이 실시간으로 녹음되고 인식되기를 원한다.
UFR-STT-020: [텍스트변환] 회의록 시스템으로서 | 나는, 인식된 발언을 회의록에 기록하기 위해 | 음성을 텍스트로 변환하고 싶다.
UFR-RAG-010: [전문용어감지] 회의 참석자로서 | 나는, 업무 지식이 없어도 회의록을 정확히 작성하기 위해 | 전문용어가 자동으로 감지되고 맥락에 맞는 설명을 제공받고 싶다.
UFR-RAG-020: [맥락기반용어설명] 회의 참석자로서 | 나는, 전문용어를 맥락에 맞게 이해하기 위해 | 관련 회의록과 업무 이력을 바탕으로 실용적인 설명을 제공받고 싶다.
UFR-COLLAB-010: [회의록수정동기화] 회의 참석자로서 | 나는, 회의록을 함께 검증하기 위해 | 회의록을 수정하고 실시간으로 다른 참석자와 동기화하고 싶다.
UFR-COLLAB-020: [충돌해결] 회의 참석자로서 | 나는, 동시 수정 상황에서도 내용을 잃지 않기 위해 | 안건별로 충돌 없이 편집하고 싶다.
UFR-COLLAB-030: [검증완료] 회의 참석자로서 | 나는, 회의록의 정확성을 보장하기 위해 | 각 안건을 검증하고 완료 표시를 하고 싶다.
UFR-TODO-010: [Todo할당] Todo 시스템으로서 | 나는, AI가 추출한 Todo를 담당자에게 전달하기 위해 | Todo를 실시간으로 할당하고 회의록과 연결하고 싶다.
UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo를 처리하고 회의록에 반영하기 위해 | Todo를 완료하고 회의록에 자동 반영하고 싶다.
UFR-TODO-040: [Todo관리] Todo 담당자로서 | 나는, 나의 Todo를 효율적으로 관리하기 위해 | Todo 목록을 조회하고 상태를 변경하고 편집하고 싶다.
UFR-NOTI-010: [알림발송] Notification 시스템으로서 | 나는, 사용자에게 중요한 이벤트를 알리기 위해 | 주기적으로 알림 대상을 확인하여 이메일을 발송하고 싶다.

View File

@ -0,0 +1,454 @@
# 유저스토리 v2.2.0 → v2.3.0 변경사항 보고서
**작성일**: 2025-10-25
**작성자**: 지수 (Product Designer), 민준 (Product Owner)
**문서 버전**: 1.0
---
## 📋 개요
본 보고서는 AI기반 회의록 작성 및 이력 관리 개선 서비스의 유저스토리 문서가 v2.2.0에서 v2.3.0으로 업데이트되면서 변경된 내용과 그 의미를 분석합니다.
### 요약 통계
| 항목 | v2.2.0 | v2.3.0 | 변화 |
|------|--------|--------|------|
| **유저스토리 수** | 25개 | 27개 | +2개 (+8%) |
| **신규 추가** | - | 5개 | UFR-USER-010, UFR-USER-020, UFR-MEET-015, UFR-AI-030, UFR-NOTI-010 |
| **삭제/전환** | - | 2개 | AFR-USER-010, AFR-USER-020 → UFR로 전환 |
| **AFR 코드** | 2개 | 0개 | -2개 (100% 제거) |
| **UFR 코드** | 23개 | 27개 | +4개 (+17%) |
| **평균 상세도** | 20-30줄 | 60-100줄 | **약 3배 증가** |
| **프로토타입 연계** | 부분적 | 100% (10개 화면) | - |
| **표준 형식 적용** | 0% | 100% (27개) | - |
---
## 📊 한눈에 보는 변경사항
```
v2.2.0 (25개) v2.3.0 (27개)
┌─────────────────┐ ┌─────────────────┐
│ AFR-USER-010 │ ──────────────────>│ UFR-USER-010 ✨ │ (로그인 상세화)
│ AFR-USER-020 │ ──────────────────>│ UFR-USER-020 ✨ │ (대시보드 재설계)
├─────────────────┤ ├─────────────────┤
│ UFR-MEET-010 │ ──────────────────>│ UFR-MEET-010 ✨ │ (회의예약 개선)
│ │ │ UFR-MEET-015 🆕 │ (참석자 실시간 초대)
│ UFR-MEET-020 │ ──────────────────>│ UFR-MEET-020 ✨ │ (템플릿선택 상세화)
│ UFR-MEET-030 │ ──────────────────>│ UFR-MEET-030 ✨ │ (회의시작 4개 탭)
│ UFR-MEET-040 │ ──────────────────>│ UFR-MEET-040 ✨ │ (회의종료 3가지 액션)
│ UFR-MEET-050 │ ──────────────────>│ UFR-MEET-050 ✨ │ (최종확정 2가지 시나리오)
│ UFR-MEET-046 │ ──────────────────>│ UFR-MEET-046 ✨ │ (목록조회 샘플 30개)
│ UFR-MEET-047 │ ──────────────────>│ UFR-MEET-047 ✨ │ (상세조회 관련회의록)
│ UFR-MEET-055 │ ──────────────────>│ UFR-MEET-055 ✨ │ (회의록수정 3가지 시나리오)
├─────────────────┤ ├─────────────────┤
│ UFR-AI-010 │ ──────────────────>│ UFR-AI-010 │
│ UFR-AI-020 │ ──────────────────>│ UFR-AI-020 │
│ │ │ UFR-AI-030 🆕🎯 │ (실시간 AI 제안 - 차별화!)
│ UFR-AI-035 │ ──────────────────>│ UFR-AI-035 │
│ UFR-AI-036 │ ──────────────────>│ UFR-AI-036 │
│ UFR-AI-040 │ ──────────────────>│ UFR-AI-040 │
├─────────────────┤ ├─────────────────┤
│ UFR-STT-010 │ ──────────────────>│ UFR-STT-010 │
│ UFR-STT-020 │ ──────────────────>│ UFR-STT-020 │
├─────────────────┤ ├─────────────────┤
│ UFR-RAG-010 │ ──────────────────>│ UFR-RAG-010 │
│ UFR-RAG-020 │ ──────────────────>│ UFR-RAG-020 │
├─────────────────┤ ├─────────────────┤
│ UFR-COLLAB-010 │ ──────────────────>│ UFR-COLLAB-010 │
│ UFR-COLLAB-020 │ ──────────────────>│ UFR-COLLAB-020 │
│ UFR-COLLAB-030 │ ──────────────────>│ UFR-COLLAB-030 │
├─────────────────┤ ├─────────────────┤
│ UFR-TODO-010 │ ──────────────────>│ UFR-TODO-010 │
│ UFR-TODO-030 │ ──────────────────>│ UFR-TODO-030 │
│ UFR-TODO-040 │ ──────────────────>│ UFR-TODO-040 │
└─────────────────┘ ├─────────────────┤
│ UFR-NOTI-010 🆕 │ (알림발송 - 폴링 방식)
└─────────────────┘
범례:
🆕 = 완전 신규 추가
🎯 = 차별화 핵심 기능
✨ = 대폭 개선 (프로토타입 기반 재작성)
```
---
## 🎯 핵심 변경사항
### 1. 신규 추가된 유저스토리 (5개)
#### 1.1 UFR-USER-010: 로그인 🆕
- **이전**: AFR-USER-010 (간략한 인증 설명)
- **변경**: UFR-USER-010으로 전환 및 상세화
- **의미**:
- 로그인 프로세스 단계별 명시 (Enter 키 동작, 로딩 상태 등)
- 예외처리 시나리오 구체화 (사번 미입력, 비밀번호 8자 미만 등)
- 프로토타입 `01-로그인.html`과 1:1 매핑
#### 1.2 UFR-USER-020: 대시보드 🆕
- **이전**: AFR-USER-020 (간략한 대시보드 설명)
- **변경**: UFR-USER-020으로 전환 및 대폭 확장
- **의미**:
- 통계 블록, 최근 회의, 나의 Todo, 나의 회의록 위젯 상세 명세
- FAB 버튼 2가지 액션 (회의예약/바로 시작) 명확화
- 프로토타입 `02-대시보드.html`과 1:1 매핑
#### 1.3 UFR-MEET-015: 참석자 실시간 초대 🆕
- **이전**: 없음
- **변경**: 완전 신규 추가
- **의미**:
- 회의 진행 중 "참석자" 탭에서 실시간으로 참석자 추가 기능
- 검색 모달 → 추가 → WebSocket 동기화 → 알림 발송 흐름 명시
- **효과**: 회의 진행 중 동적 참석자 관리로 유연성 향상
- 프로토타입 `05-회의진행.html`의 "참석자" 탭과 연계
#### 1.4 UFR-AI-030: 실시간 AI 제안 🆕🎯
- **이전**: 없음
- **변경**: 완전 신규 추가
- **의미**:
- **차별화 전략 "지능형 회의 진행 지원" 실현**
- STT 텍스트 실시간 분석 → 주요 내용 감지 → AI 제안 카드 생성
- 제안 카드에서 메모 탭으로 드래그 앤 드롭으로 추가
- **효과**: 회의 중 놓치는 내용 최소화, 차별화 핵심 기능
- 프로토타입 `05-회의진행.html`의 "AI 제안" 탭과 연계
#### 1.5 UFR-NOTI-010: 알림 발송 🆕
- **이전**: 없음 (암묵적으로 Meeting Service에서 직접 발송)
- **변경**: Notification 서비스의 독립적인 유저스토리로 추가
- **의미**:
- **알림 아키텍처를 폴링 방식으로 통일**
- 1분 간격 폴링 → 이메일 발송 → 최대 3회 재시도
- 6가지 알림 유형 명시 (Todo 할당, Todo 완료, 회의 시작, 회의록 확정, 참석자 초대, 회의록 수정)
- **효과**: Notification 서비스 독립성 확보, 시스템 안정성 향상
---
### 2. 대폭 개선된 유저스토리 (주요 8개)
#### 2.1 UFR-MEET-010: 회의예약
- **변경사항**:
- 수행절차 10단계 명시 (FAB 버튼 → 입력 → 저장/완료)
- 입력 필드별 상세 명세 (타입, 필수 여부, 최대/최소값, UI 요소)
- 임시저장/예약 완료 2가지 시나리오 구분
- 예외처리 7가지 추가 (제목 미입력, 과거 날짜, 참석자 미선택 등)
- **의미**: 프로토타입 `03-회의예약.html` 기반 전면 재작성
#### 2.2 UFR-MEET-030: 회의시작
- **변경사항**:
- 회의 진행 화면 4개 탭 상세 명세 (녹음/메모, 참석자, AI 제안, 안건)
- 녹음 시작/일시정지/재시작 플로우 명시
- 참석자 상태 표시 (온라인/오프라인/참석중)
- 탭별 UI 요소와 인터랙션 상세화
- **의미**: 프로토타입 `05-회의진행.html` 4개 탭 구조 반영
#### 2.3 UFR-MEET-040: 회의종료
- **변경사항**:
- 회의 종료 후 3가지 액션 명시 (바로 확정, 나중에 확정, 검토 후 확정)
- 각 액션별 이동 화면 명확화
- 안건 요약 및 검증 상태 표시 추가
- **의미**: 프로토타입 `07-회의종료.html` 반영, 사용자 선택권 강화
#### 2.4 UFR-MEET-050: 최종확정
- **변경사항**:
- 2가지 시나리오 분리 (검토 후 확정, 회의 종료 화면에서 바로 확정)
- 안건별 검증 완료 여부 체크 로직 추가
- 미검증 안건 있을 시 확정 불가 정책 명시
- **의미**: 회의록 품질 보증 메커니즘 강화
#### 2.5 UFR-MEET-046: 회의록목록조회
- **변경사항**:
- 샘플 데이터 30개 명시 (제목, 날짜, 상태, 검증 현황 등)
- 필터/정렬 기능 상세화 (기간, 상태, 폴더별)
- 상태 배지 5종 추가 (진행중, 검토중, 확정완료 등)
- **의미**: 프로토타입 `12-회의록목록조회.html` 반영
#### 2.6 UFR-MEET-047: 회의록상세조회
- **변경사항**:
- 관련 회의록 섹션 추가 (AI가 자동 연결한 회의록 3개 표시)
- 안건별 검증 상태 표시 추가
- 용어 팝업 연계 (UFR-RAG-010) 명시
- **의미**: 프로토타입 `10-회의록상세조회.html` 반영, RAG 기능 연계
#### 2.7 UFR-MEET-055: 회의록수정
- **변경사항**:
- 3가지 진입 시나리오 명시 (회의종료 화면, 목록 화면, 상세조회 화면)
- 실시간 협업 플로우 상세화 (UFR-COLLAB-010, UFR-COLLAB-020 연계)
- 수정 저장/임시저장/취소 3가지 액션 구분
- **의미**: 프로토타입 `11-회의록수정.html` 반영, 협업 기능 강화
#### 2.8 UFR-COLLAB-020: 충돌해결
- **변경사항**:
- 안건 기반 충돌 방지 메커니즘 상세화
- 동일 안건 동시 수정 시 경고 표시 및 잠금 정책 명시
- 충돌 해결 시나리오 3가지 (대기, 새 안건 작성, 취소)
- **의미**: 실시간 협업 안정성 강화
---
### 3. 유지된 유저스토리 (14개)
다음 유저스토리들은 v2.2.0과 v2.3.0에서 ID와 핵심 내용이 유지되었습니다:
- UFR-AI-010 (회의록 자동 작성)
- UFR-AI-020 (Todo 자동 추출)
- UFR-AI-035 (섹션 AI 요약)
- UFR-AI-036 (AI 한줄 요약)
- UFR-AI-040 (관련 회의록 연결)
- UFR-STT-010 (음성 녹음 인식)
- UFR-STT-020 (텍스트 변환)
- UFR-RAG-010 (전문용어 감지)
- UFR-RAG-020 (맥락 기반 용어 설명)
- UFR-COLLAB-010 (회의록 수정 동기화)
- UFR-COLLAB-030 (검증 완료)
- UFR-TODO-010 (Todo 할당)
- UFR-TODO-030 (Todo 완료 처리)
- UFR-TODO-040 (Todo 관리)
---
## 📈 문서 품질 개선
### 3.1 유저스토리 형식 표준화
#### Before (v2.2.0) - 자유 형식
```
UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, ...
- 시나리오: 회의 예약 및 참석자 초대
회의 예약 화면에 접근한 상황에서 | ...
[입력 요구사항]
- 회의 제목: 최대 100자 (필수)
...
[처리 결과]
- 회의가 예약됨
...
- M/13
```
#### After (v2.3.0) - 표준 5단계 형식
```
### UFR-MEET-010: [회의예약] 회의 생성자로서 | 나는, ...
**수행절차:**
1. 대시보드에서 "회의예약" FAB 버튼 클릭
2. 회의 제목 입력 (최대 100자)
3. 날짜 선택 (오늘 이후 날짜, 달력 UI)
...
10. "임시저장" 버튼 또는 "예약 완료" 버튼 클릭
**입력:**
- 회의 제목: 텍스트 입력, 필수, 최대 100자, 문자 카운터 표시
- 날짜: date 타입, 필수, 오늘 이후 날짜만 선택 가능
...
**출력/결과:**
- 예약 완료: "회의가 예약되었습니다" 토스트 메시지, 대시보드로 이동
- 임시저장: "임시 저장되었습니다" 토스트 메시지
...
**예외처리:**
- 제목 미입력: "회의 제목을 입력해주세요" 토스트, 제목 필드 포커스
- 과거 날짜 선택: "과거 날짜는 선택할 수 없습니다" 토스트
...
**관련 유저스토리:**
- UFR-USER-020: 대시보드 조회
- UFR-MEET-020: 템플릿선택
```
### 3.2 개선 효과
| 섹션 | 개선 효과 |
|------|-----------|
| **수행절차** | 단계별 명확한 작업 흐름, 개발자가 UI 플로우 이해 가능 |
| **입력** | 필드 타입, 검증 규칙, UI 요소 상세 명세, API 명세서 작성 기준 제공 |
| **출력/결과** | 성공/실패 시나리오별 응답 명시, 테스트 케이스 작성 기준 제공 |
| **예외처리** | 에러 상황별 처리 방법 구체화, QA 시나리오 명확화 |
| **관련 유저스토리** | 기능 간 연계성 추적, 통합 테스트 범위 파악 용이 |
---
## 🏗️ 프로토타입 연계 강화
v2.3.0에서는 모든 유저스토리가 프로토타입 화면과 명확하게 연계되었습니다.
| 프로토타입 화면 | 연계 유저스토리 | 상태 |
|----------------|----------------|------|
| 01-로그인.html | UFR-USER-010 | ✅ 1:1 매핑 |
| 02-대시보드.html | UFR-USER-020 | ✅ 1:1 매핑 |
| 03-회의예약.html | UFR-MEET-010 | ✅ 1:1 매핑 |
| 04-템플릿선택.html | UFR-MEET-020 | ✅ 1:1 매핑 |
| 05-회의진행.html | UFR-MEET-030, UFR-MEET-015 (신규), UFR-AI-030 (신규) | ✅ 1:N 매핑 |
| 07-회의종료.html | UFR-MEET-040 | ✅ 1:1 매핑 |
| 10-회의록상세조회.html | UFR-MEET-047 | ✅ 1:1 매핑 |
| 11-회의록수정.html | UFR-MEET-055 | ✅ 1:1 매핑 |
| 12-회의록목록조회.html | UFR-MEET-046 | ✅ 1:1 매핑 |
| 08-최종확정.html | UFR-MEET-050 | ✅ 1:1 매핑 |
**결과**: 10개 프로토타입 화면 100% 유저스토리 연계 완료
---
## 🔑 핵심 아키텍처 변경
### 알림 아키텍처: 실시간 → 폴링 방식
#### Before (v2.2.0)
```
[Meeting Service] ──(실시간 발송)──> [Notification Service] ──> [Email]
Todo 할당 발생 → 즉시 이메일 발송
```
**문제점**:
- Meeting Service와 Notification Service 간 강한 결합
- 이메일 발송 실패 시 Meeting Service에 영향
#### After (v2.3.0)
```
[Meeting Service] ──(DB 레코드 생성)──> [Notification 테이블]
(1분 간격 폴링)
[Notification Service] ──> [Email]
(발송 상태 업데이트)
```
**개선 효과**:
- ✅ **Notification 서비스 독립성 강화**: 마이크로서비스 간 느슨한 결합
- ✅ **시스템 안정성 향상**: 이메일 발송 실패 시 자동 재시도 (최대 3회)
- ✅ **확장성 확보**: 폴링 주기 조정으로 트래픽 제어 가능
- ✅ **모니터링 용이**: 발송 대기/성공/실패 상태 DB에서 추적
---
## 💡 변경의 의미와 개선 효과
### 1. 사용자 경험 (UX) 개선
| 영역 | 개선 내용 | 효과 |
|------|----------|------|
| **회의 진행 중 유연성** | UFR-MEET-015 (참석자 실시간 초대) | 회의 중 동적 참석자 관리 가능 |
| **회의 중 놓침 방지** | UFR-AI-030 (실시간 AI 제안) 🎯 | 차별화 핵심 기능, 회의 중 주요 내용 실시간 감지 |
| **회의 종료 후 선택권** | UFR-MEET-040 (3가지 액션) | 바로 확정/나중에 확정/검토 후 확정 |
| **회의록 품질 보증** | UFR-MEET-050 (검증 후 확정) | 미검증 안건 있을 시 확정 불가 정책 |
| **실시간 협업 안정성** | UFR-COLLAB-020 (안건 기반 충돌 방지) | 동일 안건 동시 수정 시 경고 및 잠금 |
### 2. 기능적 개선
| 영역 | 개선 내용 | 효과 |
|------|----------|------|
| **알림 시스템 안정성** | UFR-NOTI-010 (폴링 방식) | Notification 서비스 독립성 확보, 재시도 메커니즘 |
| **차별화 전략 실현** | UFR-AI-030 (실시간 AI 제안) 🎯 | "지능형 회의 진행 지원" 구체화 |
| **프로토타입 정합성** | 10개 화면 100% 매핑 | 기획-디자인-개발 간 일관성 확보 |
| **유저스토리 표준화** | 5단계 표준 형식 | 개발 가이드 역할 강화, API 명세서 작성 기준 제공 |
### 3. 문서화 개선
| 영역 | 개선 내용 | 효과 |
|------|----------|------|
| **상세도 3배 증가** | 20-30줄 → 60-100줄 | 개발자가 구현에 필요한 모든 정보 확보 |
| **AFR 코드 폐지** | AFR → UFR 통일 | 유저스토리 체계 단순화 |
| **예외처리 명시** | 각 유저스토리별 5-7개 예외 시나리오 | QA 테스트 케이스 작성 기준 제공 |
| **관련 유저스토리 연계** | 기능 간 의존성 추적 | 통합 테스트 범위 명확화 |
---
## 📋 권장 후속 조치
### 🔴 긴급 (1주 내)
- [ ] **신규 유저스토리 3개 기반 API 설계**
- UFR-MEET-015: 참석자 실시간 초대 API
- UFR-AI-030: 실시간 AI 제안 API (SSE 또는 WebSocket)
- UFR-NOTI-010: 알림 폴링 및 발송 API
- [ ] **알림 아키텍처 폴링 방식 반영**
- 물리 아키텍처 다이어그램 업데이트
- Notification 테이블 스키마 정의
- 폴링 스케줄러 설계
- [ ] **프로토타입 ↔ 유저스토리 1:1 매핑 검증**
- 10개 화면별 유저스토리 매핑 검증
- 누락된 화면 또는 유저스토리 확인
### 🟡 중요 (2주 내)
- [ ] **API 설계서 v2.3.0 기반 전면 업데이트**
- 입력/출력 명세 반영 (타입, 필수 여부, 검증 규칙)
- 예외처리 시나리오 → HTTP 상태 코드 및 에러 메시지 매핑
- 관련 유저스토리 기반 API 그룹핑
- [ ] **예외처리 시나리오 → 테스트 케이스 전환**
- 각 유저스토리의 예외처리 섹션을 테스트 케이스로 변환
- 입력 검증 테스트 케이스 작성
- [ ] **관련 유저스토리 기반 통합 테스트 시나리오 작성**
- 예: UFR-MEET-010 → UFR-MEET-020 → UFR-MEET-030 전체 플로우 테스트
### 🟢 일반 (3주 내)
- [ ] **유저스토리별 개발 우선순위 재평가**
- 신규 유저스토리 3개 우선순위 결정
- 차별화 핵심 기능 (UFR-AI-030) 우선 개발 검토
- [ ] **신규 기능 3개 개발 일정 수립**
- UFR-MEET-015: 참석자 실시간 초대
- UFR-AI-030: 실시간 AI 제안 (Sprint 목표로 권장)
- UFR-NOTI-010: 알림 발송
- [ ] **프로토타입 기반 개발 가이드 작성**
- 프로토타입 → 유저스토리 → API → 컴포넌트 매핑 가이드
- 프론트엔드 개발자를 위한 프로토타입 활용 가이드
---
## 🔍 핵심 시사점 (Key Takeaways)
1. **v2.3.0은 프로토타입 분석을 통해 유저스토리를 전면 재정비한 버전**
- 10개 프로토타입 화면과 100% 매핑
- 실제 UI/UX 플로우를 유저스토리에 반영
2. **신규 기능 3개 추가로 차별화 강화**
- 특히 UFR-AI-030 (실시간 AI 제안)은 차별화 핵심 기능
3. **알림 아키텍처 폴링 방식으로 통일하여 시스템 안정성 확보**
- Notification 서비스 독립성 강화
- 재시도 메커니즘으로 안정성 향상
4. **유저스토리 형식 표준화로 개발 가이드 역할 강화**
- 5단계 표준 형식 (수행절차, 입력, 출력/결과, 예외처리, 관련 유저스토리)
- API 명세서 및 테스트 케이스 작성 기준 제공
5. **평균 유저스토리 상세도 약 3배 증가로 품질 대폭 향상**
- 개발자가 구현에 필요한 모든 정보 포함
- 예외처리, 검증 규칙, UI 요소까지 상세 명시
6. **기존 24개 유저스토리 ID 승계하여 연속성 유지**
- AFR-USER-010 → UFR-USER-010 전환
- 기존 설계 문서와의 연계성 유지
7. **프로토타입-유저스토리 1:1 매핑으로 개발 명확성 확보**
- 기획-디자인-개발 간 일관성 확보
- 개발 우선순위 및 Sprint 계획 수립 용이
---
## 📎 참고 자료
- **상세 분석 (JSON)**: `claude/userstory-comparison-v2.2.0-to-v2.3.0.json` (19KB)
- **상세 분석 (Markdown)**: `claude/userstory-comparison-v2.2.0-to-v2.3.0.md` (16KB)
- **요약 분석**: `claude/userstory-comparison-summary.md` (11KB)
- **유저스토리 v2.2.0 백업**: `design/userstory_v2.2.0_backup.md`
- **유저스토리 v2.3.0 현재**: `design/userstory.md`
---
**보고서 작성**: 지수 (Product Designer), 민준 (Product Owner)
**분석 일시**: 2025-10-25
**문서 버전**: 1.0

View File

@ -0,0 +1,762 @@
# 회의진행 화면 개선안 종합 보고서
**작성일**: 2025-10-25
**버전**: v1.0
**대상**: 회의록 작성 서비스 MVP
---
## 📋 목차
1. [논의 배경 및 목적](#1-논의-배경-및-목적)
2. [MVP 핵심 가치 재확인](#2-mvp-핵심-가치-재확인)
3. [최종 개선안](#3-최종-개선안)
4. [공수 및 일정 영향 분석](#4-공수-및-일정-영향-분석)
5. [유저스토리 수정안](#5-유저스토리-수정안)
6. [화면설계 수정안](#6-화면설계-수정안)
7. [프로토타입 수정 가이드](#7-프로토타입-수정-가이드)
8. [v2.0 백로그](#8-v20-백로그)
---
## 1. 논의 배경 및 목적
### 1.1 논의 내용
회의 진행 화면의 다음 기능들에 대해 MVP 관점에서 재검토:
- **회의 참석자 권한 구분**: 생성자 vs 일반 참석자
- **메모 기능**: 개인 메모 vs 공유 메모
- **용어 설명**: 회사 특화 용어 처리 방안
- **관련 회의록**: 표시 방식 및 활용도
- **중도 퇴장**: 화면 전환 방식에 따른 구현
### 1.2 목적
- MVP 범위 명확화 및 불필요한 기능 제거
- 핵심 가치 실현에 집중
- 예산 및 일정 준수
---
## 2. MVP 핵심 가치 재확인
### 2.1 서비스 핵심 가치
> **"업무지식이 없어도 누락 없이 정확하게 회의록을 작성"**
### 2.2 MVP 필수 기능
1. ✅ AI STT로 실시간 음성 인식
2. ✅ AI가 주요 내용 자동 추출 및 제시
3. ✅ 회의 종료 후 AI 자동 요약 생성
4. ✅ 회의록 확인 및 기본 수정
5. ✅ 회의록 공유 (이메일/링크)
### 2.3 MVP 제외 기능 (v2.0 이관)
- ❌ 복잡한 메모 입력/편집 기능
- ❌ 실시간 협업 메모 동기화
- ❌ 참석자별 세밀한 권한 관리 UI
- ❌ 용어 사전 직접 검색 기능
---
## 3. 최종 개선안
### 3.1 회의 참석자 권한
#### 현재 문제점
- 생성자 vs 참석자로 이분법적 구분
- 회의 생성자도 참석자 중 한 명이라는 관점 누락
#### 개선안
**회의 생성자 = 특별 권한을 가진 참석자**
```
모든 참석자 공통 기능:
- AI 제시 주요 내용 확인 및 체크
- 회의록 실시간 확인
- 용어 설명 확인
- 관련 회의록 확인
- "나가기" 버튼으로 중도 퇴장
회의 생성자 전용 기능:
- 회의 종료 버튼 (일반 참석자에게는 숨김)
- 녹음 제어 (일시정지/재개/종료)
```
#### 구현 방식
```javascript
// 버튼 조건부 표시
if (currentUser.id === meeting.creator_id) {
// 회의 종료 버튼 표시
showEndMeetingButton();
showRecordingControls();
}
```
---
### 3.2 메모 기능
#### 현재 문제점
- 텍스트 입력 메모 구현 시 공수 증가
- 공유 메모 vs 개인 메모 선택 어려움
- 실시간 동기화 복잡도
#### 개선안 (MVP 최소화)
**AI 제시 내용 체크박스 방식**
```
┌─────────────────────────────────┐
│ 🤖 AI가 파악한 주요 내용 │
├─────────────────────────────────┤
│ ☐ 예산 500만원 증액 합의 │
│ ☐ 일정 2주 연장 논의 │
│ ☐ 외주 업체 3곳 검토 │
└─────────────────────────────────┘
```
**특징:**
- 참석자는 클릭만 (텍스트 입력 없음)
- 개인별 체크 (다른 사람 체크 안 보임)
- AI 요약 시 체크 수에 따라 가중치 부여
- 편집 충돌 없음
**공수 절감:**
- 기존 방식 (공유+개인 메모): 38일
- 체크박스 방식: 7일
- **절감률: 82%**
---
### 3.3 용어 설명 기능
#### 현재 문제점
- 일반 전문용어와 회사 특화 용어 구분 필요
- RAG 구축은 MVP 범위 초과
#### 개선안
**간소화된 JSON 용어 사전 + AI 보조**
##### 3.3.1 회사 용어 사전 구조
```json
// config/terms-dictionary.json
{
"terms": [
{
"keyword": "레거시",
"aliases": ["구시스템", "기존ERP"],
"definition": "2020년 구축한 우리 회사 통합 ERP 시스템",
"context": "현재 클라우드 기반 신규 시스템으로 마이그레이션 중",
"category": "시스템"
}
]
}
```
##### 3.3.2 용어 표시 방식
```
┌─────────────────────────────────┐
│ [AI 주요 내용] [용어] │
├─────────────────────────────────┤
│ 📚 회의 중 언급된 용어 │
│ │
│ 🔹 레거시 ⭐ │
│ 2020년 구축한 우리 회사 ERP │
│ (회사 용어 사전) │
│ │
│ 🔹 POC │
│ Proof of Concept │
│ (AI 일반 설명) │
└─────────────────────────────────┘
⭐ = 회사 용어 사전 등록 항목
```
##### 3.3.3 처리 로직
```python
def explain_term(term, stt_context, company_dict):
# 1. 회사 용어 사전 확인 (우선)
if term in company_dict:
return company_dict[term] + " ⭐"
# 2. AI 일반 설명 (회의 맥락 포함)
else:
return ai_model.explain(term, context=stt_context)
```
**장점:**
- ✅ RAG 대비 공수 1/5 (4일 vs 20일)
- ✅ 초기 10-20개 용어만으로 80% 커버
- ✅ 비개발자도 JSON 편집 가능
- ✅ v1.5에서 자동 학습 추가 가능
---
### 3.4 관련 회의록 기능
#### 기존 요구사항 (UFR-AI-040)
- ✅ AI가 벡터 유사도 검색으로 관련 회의록 자동 추천
- ✅ 관련도 배지 표시 (높음/중간/낮음)
- ✅ 최대 5개 회의록 연결
#### 현재 문제점
- 회의록 제목과 관련도 배지만 표시 (목록만 나열)
- 전체 회의록을 열어봐야 내용 파악 가능
- 회의 중 전체 회의록 읽을 시간 없음
#### 개선안
**기존 기능 유지 + 핵심 내용 요약 추가**
```
┌─────────────────────────────────┐
│ 📄 관련 회의록 │
├─────────────────────────────────┤
│ 🔥 95% 2024-01-15 주간 회의 │
│ 💡 현재 회의와 유사한 내용: │
│ • "예산 500만원으로 증액 1차 합의"│
│ • "외주 업체 A, B, C 3곳 후보" │
│ • "일정은 2주 더 필요하다는 의견"│
│ → 전체 회의록 보기 │
│ │
│ 🔥 78% 2024-01-08 기획 회의 │
│ 💡 현재 회의와 유사한 내용: │
│ • "POC 범위를 챗봇 기능만으로" │
│ • "개발 일정 2월 말까지" │
│ → 전체 회의록 보기 │
└─────────────────────────────────┘
```
##### 3.4.1 유사도 계산 로직
```python
def calculate_meeting_similarity(current, past):
# 1. 주제 유사도 (50%)
topic_similarity = cosine_similarity(
current.topics_vector,
past.topics_vector
)
# 2. 참석자 겹침 (20%)
attendee_overlap = len(
set(current.attendees) & set(past.attendees)
) / len(set(current.attendees))
# 3. 프로젝트/태그 일치 (30%)
project_match = 1.0 if current.project == past.project else 0.3
return (
topic_similarity * 0.5 +
attendee_overlap * 0.2 +
project_match * 0.3
)
```
##### 3.4.2 효율화 방안
```python
# 과거 회의록 저장 시 요약본 미리 생성
class Meeting:
content: str # 전체 내용
summary: str # AI 요약본 (미리 생성 - 배치)
topics: list # 주제 태그
# 실시간에는 요약본만 활용
def show_related_meetings(current):
for past in find_similar(current):
relevant_summary = extract_relevant_parts(
past.summary, # 미리 생성된 요약본
current.topics
)
```
**효과:**
- 회의록 찾는 시간: 5-10분 → 10초
- 컨텍스트 파악: 전체 읽기 → 요약으로 즉시
- 핵심 가치 강화: "업무지식 없어도" 실현
**추가 공수:** +1일 (최적화 적용 시)
---
### 3.5 중도 퇴장 기능
#### 현재 문제점
- 대시보드 → 회의진행 화면: 페이지 전환 (같은 탭)
- 브라우저 창 닫기 = 서비스 전체 종료
#### 개선안
**"나가기" 버튼 추가**
```
회의진행 화면 상단:
┌─────────────────────────────────┐
│ ← 나가기 | 회의 제목 | [회의종료] │
└─────────────────────────────────┘
```
**동작:**
```javascript
function exitMeeting() {
if (confirm('회의에서 나가시겠습니까?\n회의는 계속 진행됩니다.')) {
// 퇴장 이벤트 서버 전송
sendExitEvent(meeting.id, user.id);
// 대시보드로 복귀
navigateTo('02-대시보드.html');
}
}
```
**추가 공수:** +0.5일
---
## 4. 공수 및 일정 영향 분석
### 4.1 전체 공수 비교
| 항목 | 기존 계획 | 개선안 | 차이 |
|------|----------|--------|------|
| 메모 기능 | 38일 | 7일 | **-31일** ⬇️ |
| 용어 설명 | 20일 (RAG) | 4일 (JSON) | **-16일** ⬇️ |
| 관련 회의록 | 5일 (목록만) | 6일 (요약 추가) | **+1일** ⬆️ |
| 나가기 버튼 | - | 0.5일 | **+0.5일** ⬆️ |
| **순 절감** | - | - | **-45.5일** ⬇️ |
### 4.2 MVP 일정 영향
- ✅ 예산 및 일정 대폭 절감
- ✅ 핵심 기능에 집중 가능
- ✅ 빠른 시장 검증 가능
---
## 5. 유저스토리 수정안
### 5.1 신규 추가 (MVP 단순화)
```markdown
# 회의 참석자 공통 기능
UFR-PART-010: 회의 입장
- 모든 참석자(생성자 포함)는 대시보드에서 "참여하기"로 회의 입장
- 회의진행 화면으로 페이지 전환 (같은 탭)
UFR-PART-020: AI 주요 내용 체크 (개인별)
- AI가 추출한 주요 내용을 체크박스로 표시
- 각 참석자는 중요하다고 생각하는 항목 독립적으로 체크
- 다른 참석자의 체크 여부는 보이지 않음
- AI 요약 시 체크 수에 따라 가중치 차등 적용
UFR-PART-030: 회의 중도 퇴장
- "나가기" 버튼으로 회의에서 퇴장
- 확인 모달: "회의에서 나가시겠습니까? 회의는 계속 진행됩니다"
- 퇴장 후 대시보드로 복귀
- 회의록은 종료 시 공유됨
---
# 회의 생성자 전용 기능
UFR-HOST-010: 회의 종료 권한
- 회의 생성자만 "회의 종료" 버튼으로 회의 종료 가능
- 일반 참석자에게는 버튼 숨김
UFR-HOST-020: 녹음 제어 권한
- 회의 생성자만 녹음 일시정지/재개/종료 가능
---
# 용어 설명 기능
UFR-TERM-010: 용어 자동 감지 및 표시
- AI가 STT 분석 중 중요 용어 자동 감지
- "용어" 탭에 실시간으로 표시
UFR-TERM-020: 회사 용어 사전 우선 표시
- 회사 용어 사전(JSON)에 등록된 용어는 ⭐ 표시
- 클릭 시 회사 특화 설명 표시
- 사전에 없는 용어는 AI가 일반 설명 + 회의 맥락 제공
UFR-TERM-030: 용어 관리 (관리자 기능)
- 관리자는 회사 용어 사전 등록/수정 가능
- JSON 파일 직접 편집
```
### 5.2 수정 필요
```markdown
# 기존 수정 1
UFR-MEET-020: 회의 종료 권한
- 기존: "회의 생성자 또는 참석자가 회의 종료"
- 변경: "회의 생성자만 회의 종료 가능"
---
# 기존 수정 2 - UFR-AI-040 개선
UFR-AI-040: 관련 회의록 자동 연결 (개선)
기존 기능 (유지):
- ✅ AI가 벡터 유사도 검색으로 관련 회의록 자동 추천
- ✅ 같은 프로젝트/팀의 회의록 중 관련도 높은 순으로 표시
- ✅ 관련도 배지 표시
변경 및 개선 사항:
1. **최대 개수**: 5개 → 3개로 축소 (MVP)
2. **관련도 표시 방식**: 배지(높음/중간/낮음) → 퍼센트(95%, 78%) 변경
3. **유사 내용 요약 추가** (신규):
- AI가 추천한 각 회의록에서 현재 회의와 유사한 부분 자동 추출
- 유사한 내용을 3-5개 문장으로 요약하여 표시
- 전체 회의록을 열지 않아도 핵심 내용 파악 가능
- "전체 회의록 보기" 버튼으로 상세 내용 확인
4. **성능 최적화**:
- 과거 회의록 저장 시 요약본 미리 생성 (배치 처리)
- 실시간 요약은 캐싱된 데이터 활용
- 성능 목표: 1초 이내 표시
수행절차 (기존 유지):
1. 회의 종료 시 또는 회의록 작성 중 AI가 현재 회의 내용 분석
2. 벡터 유사도 검색을 통해 관련 회의록 탐색
3. 관련도가 높은 회의록 자동 연결 (최대 3개로 축소)
4. 각 회의록에서 유사한 내용 추출 및 요약 (신규 추가)
5. 회의 진행 화면, 회의록 상세 조회 화면에 표시
```
### 5.3 제거 (v2.0 이관)
```markdown
# v2.0 백로그로 이관
- 공유 메모 입력 기능
- 개인 메모 기능
- 사용자 직접 용어 검색 기능
- 용어 북마크 기능
- 참석자별 세밀한 권한 UI
```
---
## 6. 화면설계 수정안
### 6.1 회의진행 화면 (05-회의진행.html)
#### 6.1.1 상단 헤더 수정
```
기존:
┌─────────────────────────────────┐
│ 회의 제목 | [회의 종료] │
└─────────────────────────────────┘
변경:
┌─────────────────────────────────┐
│ ← 나가기 | 회의 제목 | [회의종료] │
└─────────────────────────────────┘
조건부 표시:
- "회의 종료" 버튼: 생성자만 표시
- "나가기" 버튼: 모든 참석자 표시
```
#### 6.1.2 탭 구조
```
┌─────────────────────────────────┐
│ [AI 주요 내용] [용어] [관련 회의록] │
├─────────────────────────────────┤
│ (탭 콘텐츠) │
└─────────────────────────────────┘
```
#### 6.1.3 AI 주요 내용 탭
```html
<div class="tab-content active">
<h3>🤖 AI가 파악한 주요 내용</h3>
<div class="ai-items">
<div class="ai-item">
<input type="checkbox" id="item1"
data-importance="high">
<label for="item1">예산 500만원 증액 합의</label>
<span class="timestamp">15:23</span>
</div>
</div>
</div>
```
#### 6.1.4 용어 탭
```html
<div class="tab-content">
<h3>📚 회의 중 언급된 용어</h3>
<div class="terms-list">
<div class="term-item company-term"
onclick="showTermDetail('legacy')">
<h4>🔹 레거시 ⭐</h4>
<p>2020년 구축한 우리 회사 ERP</p>
</div>
<div class="term-item ai-term"
onclick="showTermDetail('poc')">
<h4>🔹 POC</h4>
<p>Proof of Concept (개념 증명)</p>
</div>
</div>
</div>
<!-- 용어 상세 팝업 -->
<div class="modal" id="term-detail">
<div class="modal-content">
<h3>💡 레거시</h3>
<p class="definition">
2020년 구축한 우리 회사 통합 ERP 시스템
</p>
<p class="context">
현재 클라우드 기반 신규 시스템으로
마이그레이션 중입니다.
</p>
<button onclick="closeModal()">닫기</button>
</div>
</div>
```
#### 6.1.5 관련 회의록 탭
```html
<div class="tab-content">
<h3>📄 관련 회의록</h3>
<div class="related-meetings">
<div class="meeting-card">
<div class="similarity-badge">🔥 95% 관련도</div>
<h4>2024-01-15 주간 회의</h4>
<div class="meeting-summary">
<p class="summary-label">💡 현재 회의와 유사한 내용:</p>
<ul>
<li>"예산 500만원으로 증액 1차 합의"</li>
<li>"외주 업체 A, B, C 3곳 후보"</li>
<li>"일정은 2주 더 필요하다는 의견"</li>
</ul>
</div>
<button class="btn-link"
onclick="openMeeting('meeting-123')">
전체 회의록 보기 →
</button>
</div>
</div>
</div>
```
### 6.2 인터랙션 추가
```markdown
# 회의진행 화면 인터랙션
1. **나가기 버튼**
- 클릭 시 확인 모달 표시
- 확인 시 대시보드로 페이지 전환
2. **AI 주요 내용 체크박스**
- 체크 시 개인 선택 저장 (로컬 + 서버)
- 다른 사람 체크 여부는 표시 안 함
3. **용어 항목 클릭**
- 상세 설명 모달 표시
- 회사 용어는 ⭐ 배지 표시
4. **관련 회의록 카드**
- 유사도 점수 색상 코딩:
- 90% 이상: 빨강 (#FF6B6B)
- 70-89%: 주황 (#FFA94D)
- 50-69%: 노랑 (#FFD43B)
- "전체 회의록 보기" 버튼: 새 탭으로 열기
5. **회의 종료 버튼**
- 조건부 표시:
```javascript
if (currentUser.id === meeting.creator_id) {
showButton('endMeeting');
}
```
```
---
## 7. 프로토타입 수정 가이드
### 7.1 파일 수정 목록
1. `05-회의진행.html` - 메인 화면 구조 변경
2. `common.js` - 권한 체크 함수 추가
3. `02-대시보드.html` - 참여하기 버튼 동작 확인
### 7.2 주요 수정 사항
#### 7.2.1 권한 체크 함수 (common.js)
```javascript
/**
* 현재 사용자가 회의 생성자인지 확인
*/
function isCreator(meetingId, userId) {
const meeting = getMeetingById(meetingId);
return meeting && meeting.creator_id === userId;
}
/**
* 생성자 전용 UI 표시/숨김
*/
function updateCreatorUI(meetingId, userId) {
const isCreator = isCreator(meetingId, userId);
// 회의 종료 버튼
const endBtn = document.getElementById('end-meeting-btn');
if (endBtn) {
endBtn.style.display = isCreator ? 'block' : 'none';
}
// 녹음 제어 버튼들
const recordControls = document.querySelectorAll('.record-control');
recordControls.forEach(btn => {
btn.style.display = isCreator ? 'inline-block' : 'none';
});
}
```
#### 7.2.2 나가기 버튼 (05-회의진행.html)
```javascript
function exitMeeting() {
if (confirm('회의에서 나가시겠습니까?\n회의는 계속 진행됩니다.')) {
// 퇴장 이벤트 전송
sendExitEvent(currentMeeting.id, currentUser.id);
// 대시보드로 복귀
navigateTo('02-대시보드.html');
}
}
```
#### 7.2.3 AI 주요 내용 체크박스
```javascript
// 개인별 체크 저장
function handleAIItemCheck(itemId, checked) {
const checkData = {
meeting_id: currentMeeting.id,
user_id: currentUser.id,
item_id: itemId,
checked: checked,
timestamp: new Date().toISOString()
};
// 로컬 저장
saveLocalCheck(checkData);
// 서버 전송 (비동기)
sendCheckToServer(checkData);
}
```
#### 7.2.4 용어 표시 로직
```javascript
// 용어 목록 렌더링
function renderTerms(terms) {
const container = document.getElementById('terms-list');
container.innerHTML = terms.map(term => `
<div class="term-item ${term.isCompanyTerm ? 'company-term' : 'ai-term'}"
onclick="showTermDetail('${term.id}')">
<h4>🔹 ${term.keyword} ${term.isCompanyTerm ? '⭐' : ''}</h4>
<p>${term.shortDefinition}</p>
</div>
`).join('');
}
// 용어 상세 표시
function showTermDetail(termId) {
const term = getTermById(termId);
const modal = document.getElementById('term-detail-modal');
modal.querySelector('.term-title').textContent = term.keyword;
modal.querySelector('.term-definition').textContent = term.definition;
modal.querySelector('.term-context').textContent = term.context;
modal.classList.add('show');
}
```
#### 7.2.5 관련 회의록 표시
```javascript
// 관련 회의록 렌더링
function renderRelatedMeetings(meetings) {
const container = document.getElementById('related-meetings');
container.innerHTML = meetings.map(meeting => `
<div class="meeting-card">
<div class="similarity-badge"
style="background-color: ${getSimilarityColor(meeting.similarity)}">
🔥 ${Math.round(meeting.similarity * 100)}% 관련도
</div>
<h4>${meeting.date} ${meeting.title}</h4>
<div class="meeting-summary">
<p class="summary-label">💡 현재 회의와 유사한 내용:</p>
<ul>
${meeting.relevantSummary.map(item => `<li>"${item}"</li>`).join('')}
</ul>
</div>
<button class="btn-link" onclick="openMeeting('${meeting.id}')">
전체 회의록 보기 →
</button>
</div>
`).join('');
}
// 유사도에 따른 색상
function getSimilarityColor(similarity) {
if (similarity >= 0.9) return '#FF6B6B'; // 빨강
if (similarity >= 0.7) return '#FFA94D'; // 주황
return '#FFD43B'; // 노랑
}
```
---
## 8. v2.0 백로그
### 8.1 메모 기능 고도화
- 공유 메모 실시간 동기화
- 개인 메모 기능
- 메모 검색 및 필터
- 메모 히스토리 추적
### 8.2 용어 사전 고도화
- 사용자 직접 용어 검색
- 용어 북마크 기능
- 용어 설명 편집 기능
- 과거 회의록 기반 자동 학습
- 전체 RAG 시스템 구축
### 8.3 권한 관리 고도화
- 세밀한 참석자 권한 설정
- 안건별 편집 권한 제어
- 참석자 강제 퇴장 기능
### 8.4 관련 회의록 고도화
- 용어-회의록 연계 표시
- 관련 문서 통합 검색
- 프로젝트 문서/위키 연동
---
## 9. 결론 및 다음 단계
### 9.1 결론
이번 개선안을 통해:
- ✅ MVP 범위 명확화
- ✅ 핵심 가치 "업무지식 없어도 누락 없이" 실현
- ✅ 개발 공수 45.5일 절감
- ✅ 예산 및 일정 준수
### 9.2 다음 단계
1. **유저스토리 업데이트** (design/userstory.md)
- 신규 스토리 추가
- 기존 스토리 수정
- v2.0 백로그 섹션 추가
2. **화면설계 업데이트** (design/uiux/uiux.md)
- 05-회의진행 화면 상세 명세
- 인터랙션 시나리오 추가
- 권한별 UI 조건 명시
3. **프로토타입 수정** (design/uiux/prototype/05-회의진행.html)
- 나가기 버튼 추가
- 체크박스 방식 메모 구현
- 용어 탭 구현
- 관련 회의록 요약 표시
4. **백엔드 API 설계 반영**
- 권한 체크 API
- 체크 데이터 저장 API
- 용어 사전 API
- 관련 회의록 추천 API
---
**보고서 종료**

View File

@ -36,6 +36,12 @@ public enum ErrorCode {
OPERATION_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "B004", "허용되지 않은 작업입니다."), OPERATION_NOT_ALLOWED(HttpStatus.BAD_REQUEST, "B004", "허용되지 않은 작업입니다."),
BUSINESS_RULE_VIOLATION(HttpStatus.BAD_REQUEST, "B005", "비즈니스 규칙 위반입니다."), BUSINESS_RULE_VIOLATION(HttpStatus.BAD_REQUEST, "B005", "비즈니스 규칙 위반입니다."),
// 회의 관련 에러
MEETING_TIME_CONFLICT(HttpStatus.CONFLICT, "M001", "해당 시간대에 이미 예약된 회의가 있습니다."),
INVALID_MEETING_TIME(HttpStatus.BAD_REQUEST, "M002", "회의 시작 시간이 종료 시간보다 늦습니다."),
MEETING_NOT_FOUND(HttpStatus.NOT_FOUND, "M003", "회의를 찾을 수 없습니다."),
INVALID_MEETING_STATUS(HttpStatus.BAD_REQUEST, "M004", "유효하지 않은 회의 상태입니다."),
// 외부 시스템 에러 (4xxx) // 외부 시스템 에러 (4xxx)
EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "외부 API 호출 중 오류가 발생했습니다."), EXTERNAL_API_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E001", "외부 API 호출 중 오류가 발생했습니다."),
DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E002", "데이터베이스 오류가 발생했습니다."), DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E002", "데이터베이스 오류가 발생했습니다."),

View File

@ -13,6 +13,32 @@
padding-bottom: 80px; padding-bottom: 80px;
} }
/* 템플릿 그리드 */
.template-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-md);
}
/* 템플릿 카드 높이 균등 */
.template-list .card {
display: flex;
flex-direction: column;
height: 100%;
}
.template-list .card-body {
flex: 1;
}
/* 모바일: 작은 간격 */
@media (max-width: 767px) {
.template-list {
gap: var(--space-sm);
}
}
/* 데스크톱: 메인 콘텐츠 조정 */ /* 데스크톱: 메인 콘텐츠 조정 */
@media (min-width: 768px) { @media (min-width: 768px) {
.main-content { .main-content {
@ -41,18 +67,16 @@
<p class="text-muted">회의 유형에 맞는 템플릿을 선택하세요</p> <p class="text-muted">회의 유형에 맞는 템플릿을 선택하세요</p>
</div> </div>
<!-- Template Cards --> <!-- Template Cards (2x2 grid) -->
<div class="template-list" style="display: flex; flex-direction: column; gap: var(--space-md);"> <div class="template-list">
<!-- 일반 회의 템플릿 --> <!-- 일반 회의 템플릿 -->
<div class="card" style="cursor: pointer;" onclick="selectTemplate('general')"> <div class="card" style="cursor: pointer;" onclick="selectTemplate('general')">
<div class="card-header"> <div class="card-header" style="display: block !important;">
<div style="display: flex; align-items: center; gap: var(--space-md);"> <div style="margin-bottom: 4px;">
<div style="font-size: 32px;">📋</div> <span style="font-size: 20px;">📋</span>
<div> <span style="font-size: 14px; font-weight: 600; margin-left: 4px;">일반 회의</span>
<h3 class="card-title">일반 회의</h3>
<p class="text-muted text-small">기본 회의록 형식</p>
</div>
</div> </div>
<div style="font-size: 11px; color: var(--text-muted);">기본 회의록 형식</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="text-small text-muted"> <div class="text-small text-muted">
@ -63,21 +87,18 @@
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<button class="btn btn-secondary btn-sm" onclick="previewTemplate(event, 'general')">미리보기</button> <button class="btn btn-primary btn-sm" onclick="selectTemplate('general')" style="width: 100%;">선택</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('general')">선택</button>
</div> </div>
</div> </div>
<!-- 스크럼 회의 템플릿 --> <!-- 스크럼 회의 템플릿 -->
<div class="card" style="cursor: pointer;" onclick="selectTemplate('scrum')"> <div class="card" style="cursor: pointer;" onclick="selectTemplate('scrum')">
<div class="card-header"> <div class="card-header" style="display: block !important;">
<div style="display: flex; align-items: center; gap: var(--space-md);"> <div style="margin-bottom: 4px;">
<div style="font-size: 32px;">🏃</div> <span style="font-size: 20px;">🏃</span>
<div> <span style="font-size: 14px; font-weight: 600; margin-left: 4px;">스크럼 회의</span>
<h3 class="card-title">스크럼 회의</h3>
<p class="text-muted text-small">데일리 스탠드업 형식</p>
</div>
</div> </div>
<div style="font-size: 11px; color: var(--text-muted);">데일리 스탠드업 형식</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="text-small text-muted"> <div class="text-small text-muted">
@ -87,21 +108,18 @@
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<button class="btn btn-secondary btn-sm" onclick="previewTemplate(event, 'scrum')">미리보기</button> <button class="btn btn-primary btn-sm" onclick="selectTemplate('scrum')" style="width: 100%;">선택</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('scrum')">선택</button>
</div> </div>
</div> </div>
<!-- 킥오프 회의 템플릿 --> <!-- 킥오프 회의 템플릿 -->
<div class="card" style="cursor: pointer;" onclick="selectTemplate('kickoff')"> <div class="card" style="cursor: pointer;" onclick="selectTemplate('kickoff')">
<div class="card-header"> <div class="card-header" style="display: block !important;">
<div style="display: flex; align-items: center; gap: var(--space-md);"> <div style="margin-bottom: 4px;">
<div style="font-size: 32px;">🚀</div> <span style="font-size: 20px;">🚀</span>
<div> <span style="font-size: 14px; font-weight: 600; margin-left: 4px;">킥오프 회의</span>
<h3 class="card-title">킥오프 회의</h3>
<p class="text-muted text-small">프로젝트 시작 회의</p>
</div>
</div> </div>
<div style="font-size: 11px; color: var(--text-muted);">프로젝트 시작 회의</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="text-small text-muted"> <div class="text-small text-muted">
@ -112,21 +130,18 @@
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<button class="btn btn-secondary btn-sm" onclick="previewTemplate(event, 'kickoff')">미리보기</button> <button class="btn btn-primary btn-sm" onclick="selectTemplate('kickoff')" style="width: 100%;">선택</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('kickoff')">선택</button>
</div> </div>
</div> </div>
<!-- 주간 회의 템플릿 --> <!-- 주간 회의 템플릿 -->
<div class="card" style="cursor: pointer;" onclick="selectTemplate('weekly')"> <div class="card" style="cursor: pointer;" onclick="selectTemplate('weekly')">
<div class="card-header"> <div class="card-header" style="display: block !important;">
<div style="display: flex; align-items: center; gap: var(--space-md);"> <div style="margin-bottom: 4px;">
<div style="font-size: 32px;">📅</div> <span style="font-size: 20px;">📅</span>
<div> <span style="font-size: 14px; font-weight: 600; margin-left: 4px;">주간 회의</span>
<h3 class="card-title">주간 회의</h3>
<p class="text-muted text-small">주간 리뷰 및 계획</p>
</div>
</div> </div>
<div style="font-size: 11px; color: var(--text-muted);">주간 리뷰 및 계획</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="text-small text-muted"> <div class="text-small text-muted">
@ -137,72 +152,12 @@
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
<button class="btn btn-secondary btn-sm" onclick="previewTemplate(event, 'weekly')">미리보기</button> <button class="btn btn-primary btn-sm" onclick="selectTemplate('weekly')" style="width: 100%;">선택</button>
<button class="btn btn-primary btn-sm" onclick="selectTemplate('weekly')">선택</button>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
<!-- Template Preview Modal -->
<div id="previewModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title" id="previewTitle">템플릿 미리보기</h2>
<button class="modal-close" onclick="closeModal('previewModal')"></button>
</div>
<div class="modal-body">
<div id="previewContent" style="max-height: 400px; overflow-y: auto;">
<!-- 섹션 리스트가 여기에 표시됨 -->
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('previewModal')">닫기</button>
<button class="btn btn-primary" onclick="customizeTemplate()">커스터마이징</button>
</div>
</div>
</div>
<!-- Template Customization Modal -->
<div id="customizeModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">템플릿 커스터마이징</h2>
<button class="modal-close" onclick="closeModal('customizeModal')"></button>
</div>
<div class="modal-body">
<p class="text-small text-muted mb-md">섹션을 드래그하여 순서를 변경하거나 삭제할 수 있습니다</p>
<div id="sectionList" style="display: flex; flex-direction: column; gap: var(--space-sm);">
<!-- 섹션 목록이 여기에 표시됨 -->
</div>
<button class="btn btn-ghost mt-md" onclick="addSection()" style="width: 100%;">+ 섹션 추가</button>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('customizeModal')">취소</button>
<button class="btn btn-primary" onclick="startMeeting()">이 템플릿으로 시작</button>
</div>
</div>
</div>
<!-- Add Section Modal -->
<div id="addSectionModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">섹션 추가</h2>
<button class="modal-close" onclick="closeModal('addSectionModal')"></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">섹션 이름</label>
<input type="text" class="form-control" id="newSectionName" placeholder="예: 기술 검토">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('addSectionModal')">취소</button>
<button class="btn btn-primary" onclick="confirmAddSection()">추가</button>
</div>
</div>
</div>
<script src="common.js"></script> <script src="common.js"></script>
<script> <script>
@ -230,35 +185,13 @@
} }
}; };
let selectedTemplate = null;
let customSections = [];
// 템플릿 미리보기
function previewTemplate(event, templateId) {
event.stopPropagation();
const template = templates[templateId];
$('#previewTitle').textContent = template.name + ' 미리보기';
const content = template.sections.map((section, index) => `
<div class="list-item">
<span class="text-muted text-small">${index + 1}.</span>
<span class="list-item-title">${section}</span>
</div>
`).join('');
$('#previewContent').innerHTML = content;
openModal('previewModal');
}
// 템플릿 선택 // 템플릿 선택
function selectTemplate(templateId) { function selectTemplate(templateId) {
selectedTemplate = templateId; const templateSections = [...templates[templateId].sections];
customSections = [...templates[templateId].sections];
// 회의 진행 화면으로 이동 // 회의 진행 화면으로 이동
saveToStorage('selectedTemplate', templateId); saveToStorage('selectedTemplate', templateId);
saveToStorage('templateSections', customSections); saveToStorage('templateSections', templateSections);
navigateTo('05-회의진행.html'); navigateTo('05-회의진행.html');
} }
@ -276,114 +209,6 @@
$('#skip-btn')?.addEventListener('click', () => { $('#skip-btn')?.addEventListener('click', () => {
skipTemplate(); skipTemplate();
}); });
// 커스터마이징 모달 열기
function customizeTemplate() {
closeModal('previewModal');
renderSectionList();
openModal('customizeModal');
}
// 섹션 리스트 렌더링
function renderSectionList() {
const sectionList = $('#sectionList');
sectionList.innerHTML = customSections.map((section, index) => `
<div class="list-item" draggable="true" data-index="${index}">
<span class="text-muted"></span>
<span class="list-item-title" style="flex: 1;">${section}</span>
<button class="btn btn-ghost btn-sm" onclick="removeSection(${index})">
<span style="color: var(--error);"></span>
</button>
</div>
`).join('');
// 드래그 이벤트 설정
setupDragAndDrop();
}
// 드래그 앤 드롭 설정
function setupDragAndDrop() {
const items = $$('#sectionList .list-item');
let draggedItem = null;
items.forEach(item => {
item.addEventListener('dragstart', function() {
draggedItem = this;
this.style.opacity = '0.5';
});
item.addEventListener('dragend', function() {
this.style.opacity = '1';
});
item.addEventListener('dragover', function(e) {
e.preventDefault();
});
item.addEventListener('drop', function(e) {
e.preventDefault();
if (draggedItem !== this) {
const draggedIndex = parseInt(draggedItem.dataset.index);
const targetIndex = parseInt(this.dataset.index);
const temp = customSections[draggedIndex];
customSections.splice(draggedIndex, 1);
customSections.splice(targetIndex, 0, temp);
renderSectionList();
}
});
});
}
// 섹션 삭제
function removeSection(index) {
if (customSections.length <= 1) {
showToast('최소 1개의 섹션이 필요합니다', 'error');
return;
}
customSections.splice(index, 1);
renderSectionList();
}
// 섹션 추가 모달 열기
function addSection() {
openModal('addSectionModal');
$('#newSectionName').value = '';
$('#newSectionName').focus();
}
// 섹션 추가 확인
function confirmAddSection() {
const name = $('#newSectionName').value.trim();
if (!name) {
showToast('섹션 이름을 입력해주세요', 'error');
return;
}
customSections.push(name);
renderSectionList();
closeModal('addSectionModal');
showToast('섹션이 추가되었습니다', 'success');
}
// 회의 시작
function startMeeting() {
if (customSections.length === 0) {
showToast('최소 1개의 섹션이 필요합니다', 'error');
return;
}
saveToStorage('selectedTemplate', selectedTemplate);
saveToStorage('templateSections', customSections);
navigateTo('05-회의진행.html');
}
// Enter 키로 섹션 추가
$('#addSectionModal')?.addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
confirmAddSection();
}
});
</script> </script>
</body> </body>
</html> </html>

View File

@ -94,6 +94,11 @@
50% { opacity: 0.3; } 50% { opacity: 0.3; }
} }
.recording-indicator.paused .recording-dot,
.recording-indicator.paused .waveform-bar {
animation-play-state: paused;
}
.recording-time { .recording-time {
font-size: var(--font-small); font-size: var(--font-small);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
@ -129,18 +134,23 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
background: var(--gray-100); background: var(--gray-100);
padding: 0 var(--space-md) 10px; padding: var(--space-md);
padding-bottom: 88px; /* 하단 버튼 영역 확보 */
max-width: none !important; /* common.css의 max-width: 900px 오버라이드 */
margin: 0 !important; /* common.css의 auto margin 제거 */
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.main-content { .main-content {
padding: 0 var(--space-lg) 88px; padding: var(--space-lg);
padding-bottom: 88px;
} }
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
.main-content { .main-content {
padding: 0 var(--space-xl) 88px; padding: var(--space-xl);
padding-bottom: 88px;
} }
} }
@ -151,6 +161,8 @@
padding: var(--space-md); padding: var(--space-md);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
width: 100%;
box-sizing: border-box;
} }
.meeting-info-grid { .meeting-info-grid {
@ -192,6 +204,11 @@
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
display: block;
} }
.tabs-header { .tabs-header {
@ -248,22 +265,19 @@
.tab-content { .tab-content {
display: none; display: none;
padding: var(--space-md); padding: var(--space-md);
width: 100%;
box-sizing: border-box;
} }
.tab-content.active { .tab-content.active {
display: block; display: block;
} }
@media (min-width: 768px) { /* 모든 탭 콘텐츠 내부 요소 텍스트 줄바꿈 강제 */
.tab-content { .tab-content * {
padding: var(--space-lg); max-width: 100%;
} word-wrap: break-word;
} overflow-wrap: break-word;
@media (min-width: 1024px) {
.tab-content {
padding: var(--space-xl);
}
} }
/* 참석자 탭 */ /* 참석자 탭 */
@ -437,6 +451,26 @@
line-height: 1.5; line-height: 1.5;
} }
/* 용어 검색 폼 */
.term-search-form {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-sm);
}
.term-search-input {
flex: 1;
font-size: var(--font-small);
padding: var(--space-sm);
}
.term-search-btn {
padding: var(--space-sm) var(--space-md);
font-size: var(--font-small);
white-space: nowrap;
flex-shrink: 0;
}
/* 용어 사전 카드 */ /* 용어 사전 카드 */
.term-item { .term-item {
background: #FAFAFA; background: #FAFAFA;
@ -564,6 +598,11 @@
height: 32px; height: 32px;
} }
.rec-icon {
width: 32px;
height: 32px;
}
.end-meeting-btn { .end-meeting-btn {
flex: 1; flex: 1;
font-size: var(--font-body); font-size: var(--font-body);
@ -580,7 +619,7 @@
</div> </div>
<div class="recording-status-bar"> <div class="recording-status-bar">
<div class="recording-indicator"> <div class="recording-indicator" id="recordingIndicator">
<span class="recording-dot"></span> <span class="recording-dot"></span>
<span class="recording-time" id="recordingTime">00:15:51</span> <span class="recording-time" id="recordingTime">00:15:51</span>
<div class="waveform"> <div class="waveform">
@ -708,13 +747,11 @@
</div> </div>
<h4 class="ai-suggestion-list-title"> <h4 class="ai-suggestion-list-title">
💬 AI가 실시간으로 분석한 제안사항 💬 AI가 실시간으로 분석한 주요 내용
</h4> </h4>
<div id="aiSuggestionList"> <div id="aiSuggestionList">
<!-- AI 제안사항이 실시간으로 추가됩니다 --> <!-- AI 제안 1 -->
<!-- 백업용 정적 샘플 데이터 (SSE 연결 실패 시 표시)
<div class="ai-suggestion-card" id="suggestion-1"> <div class="ai-suggestion-card" id="suggestion-1">
<div class="ai-suggestion-header"> <div class="ai-suggestion-header">
<span class="ai-suggestion-time">00:05:23</span> <span class="ai-suggestion-time">00:05:23</span>
@ -727,6 +764,7 @@
</div> </div>
</div> </div>
<!-- AI 제안 2 -->
<div class="ai-suggestion-card" id="suggestion-2"> <div class="ai-suggestion-card" id="suggestion-2">
<div class="ai-suggestion-header"> <div class="ai-suggestion-header">
<span class="ai-suggestion-time">00:08:45</span> <span class="ai-suggestion-time">00:08:45</span>
@ -739,6 +777,7 @@
</div> </div>
</div> </div>
<!-- AI 제안 3 -->
<div class="ai-suggestion-card" id="suggestion-3"> <div class="ai-suggestion-card" id="suggestion-3">
<div class="ai-suggestion-header"> <div class="ai-suggestion-header">
<span class="ai-suggestion-time">00:12:18</span> <span class="ai-suggestion-time">00:12:18</span>
@ -750,7 +789,6 @@
마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요 마케팅 예산 배분에 대해 SNS 광고 60%, 인플루언서 마케팅 40%로 의견이 나왔으나 추가 검토 필요
</div> </div>
</div> </div>
-->
</div> </div>
</div> </div>
@ -758,8 +796,21 @@
<div class="tab-content" id="tab-terms"> <div class="tab-content" id="tab-terms">
<h3 class="text-small font-bold mb-md">용어 사전</h3> <h3 class="text-small font-bold mb-md">용어 사전</h3>
<!-- 용어 검색 폼 -->
<div class="term-search-form">
<input
type="text"
class="form-control term-search-input"
id="termSearchInput"
placeholder="용어 검색..."
>
<button class="btn btn-primary btn-sm term-search-btn" onclick="searchTerm()">
검색
</button>
</div>
<div id="termsList"> <div id="termsList">
<div class="term-item" onclick="showTermDetail('MVP')"> <div class="term-item highlight" onclick="showTermDetail('MVP')">
<div class="term-name"> <div class="term-name">
MVP MVP
<span class="term-badge">기획</span> <span class="term-badge">기획</span>
@ -770,7 +821,7 @@
<div class="term-context">신제품 기획 회의에서 언급</div> <div class="term-context">신제품 기획 회의에서 언급</div>
</div> </div>
<div class="term-item" onclick="showTermDetail('B2C')"> <div class="term-item highlight" onclick="showTermDetail('B2C')">
<div class="term-name"> <div class="term-name">
B2C B2C
<span class="term-badge">비즈니스</span> <span class="term-badge">비즈니스</span>
@ -781,7 +832,7 @@
<div class="term-context">타겟 고객 분석 시 사용</div> <div class="term-context">타겟 고객 분석 시 사용</div>
</div> </div>
<div class="term-item" onclick="showTermDetail('PMF')"> <div class="term-item highlight" onclick="showTermDetail('PMF')">
<div class="term-name"> <div class="term-name">
PMF PMF
<span class="term-badge">전략</span> <span class="term-badge">전략</span>
@ -792,7 +843,7 @@
<div class="term-context">제품 전략 논의 중 언급</div> <div class="term-context">제품 전략 논의 중 언급</div>
</div> </div>
<div class="term-item" onclick="showTermDetail('CAC')"> <div class="term-item highlight" onclick="showTermDetail('CAC')">
<div class="term-name"> <div class="term-name">
CAC CAC
<span class="term-badge">마케팅</span> <span class="term-badge">마케팅</span>
@ -803,6 +854,102 @@
<div class="term-context">마케팅 예산 논의에서 사용</div> <div class="term-context">마케팅 예산 논의에서 사용</div>
</div> </div>
</div> </div>
<div id="termList">
<div class="term-item" onclick="showTermDetail('Mobile First')">
<div class="term-name">
Mobile First
<span class="term-badge">설계 방법론</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
모바일 환경을 우선적으로 고려하여 디자인하고, 이후 더 큰 화면으로 확장하는 설계 방법론입니다.
</div>
<div class="term-context">회의에서 언급됨 (14:23)</div>
</div>
<div class="term-item" onclick="showTermDetail('AI')">
<div class="term-name">
AI
<span class="term-badge">기술</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
Artificial Intelligence의 약자로, 인공지능을 의미합니다. 이 프로젝트에서는 회의록 자동 작성에 활용됩니다.
</div>
<div class="term-context">회의에서 5회 언급됨</div>
</div>
<div class="term-item" onclick="showTermDetail('API')">
<div class="term-name">
API
<span class="term-badge">기술</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
Application Programming Interface의 약자로, 소프트웨어 간 상호작용을 위한 인터페이스입니다.
</div>
<div class="term-context">회의에서 3회 언급됨</div>
</div>
<div class="term-item" onclick="showTermDetail('API Gateway')">
<div class="term-name">
API Gateway
<span class="term-badge">아키텍처</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
클라이언트와 백엔드 마이크로서비스 사이의 단일 진입점 역할을 하는 서버. 요청 라우팅, 인증, 속도 제한, 로드 밸런싱 등을 처리합니다.
</div>
<div class="term-context">API 설계 리뷰 회의 (2024-09-28)에서 AWS API Gateway 채택 결정</div>
</div>
<div class="term-item" onclick="showTermDetail('마이크로서비스')">
<div class="term-name">
마이크로서비스
<span class="term-badge">아키텍처</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
애플리케이션을 작고 독립적인 서비스들로 분리하여 개발하고 배포하는 아키텍처 패턴입니다.
</div>
<div class="term-context">회의에서 언급됨</div>
</div>
<div class="term-item " onclick="showTermDetail('MVP')">
<div class="term-name">
MVP
<span class="term-badge">방법론</span>
<span class="term-mention-icon">💬</span>
</div>
<div class="term-definition">
Minimum Viable Product의 약자. 최소한의 기능만 갖춘 제품으로, 시장 반응을 빠르게 확인하기 위해 개발합니다.
</div>
<div class="term-context">개발 일정 논의에서 언급</div>
</div>
<div class="term-item" onclick="showTermDetail('RESTful API')">
<div class="term-name">
RESTful API
<span class="term-badge">기술</span>
</div>
<div class="term-definition">
REST(Representational State Transfer) 아키텍처 스타일을 따르는 웹 서비스 API 설계 방식입니다.
</div>
<div class="term-context">API 설계 리뷰 회의 참조</div>
</div>
<div class="term-item" onclick="showTermDetail('JWT')">
<div class="term-name">
JWT
<span class="term-badge">보안</span>
</div>
<div class="term-definition">
JSON Web Token의 약자. 사용자 인증 정보를 안전하게 전송하기 위한 토큰 기반 인증 방식입니다.
</div>
<div class="term-context">API Gateway 보안 정책에서 채택</div>
</div>
</div>
</div> </div>
<!-- 관련 회의록 탭 --> <!-- 관련 회의록 탭 -->
@ -853,11 +1000,12 @@
<!-- 하단 고정 버튼 영역 --> <!-- 하단 고정 버튼 영역 -->
<div class="bottom-action-bar"> <div class="bottom-action-bar">
<button class="btn btn-ghost pause-btn" onclick="pauseRecording()" id="pauseBtn"> <button class="btn btn-ghost pause-btn" onclick="toggleRecording()" id="pauseBtn">
<svg class="pause-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg class="pause-icon" id="pauseIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="4" width="4" height="16" rx="1"></rect> <rect x="6" y="4" width="4" height="16" rx="1"></rect>
<rect x="14" y="4" width="4" height="16" rx="1"></rect> <rect x="14" y="4" width="4" height="16" rx="1"></rect>
</svg> </svg>
<img class="rec-icon" id="recIcon" src="img/rec.png" alt="녹음 재개" style="display: none;">
</button> </button>
<button class="btn btn-error end-meeting-btn" onclick="endMeeting()"> <button class="btn btn-error end-meeting-btn" onclick="endMeeting()">
회의 종료 회의 종료
@ -980,9 +1128,104 @@
// }); // });
} }
// 녹음 일시정지 // 용어 검색
function pauseRecording() { function searchTerm() {
const searchInput = document.getElementById('termSearchInput');
const searchText = searchInput.value.trim().toLowerCase();
if (!searchText) {
alert('검색할 용어를 입력해주세요');
return;
}
const termItems = document.querySelectorAll('.term-item');
let foundCount = 0;
termItems.forEach(item => {
const termName = item.querySelector('.term-name').textContent.toLowerCase();
const termDefinition = item.querySelector('.term-definition').textContent.toLowerCase();
// 용어명 또는 정의에 검색어가 포함되어 있는지 확인
if (termName.includes(searchText) || termDefinition.includes(searchText)) {
item.style.display = '';
item.classList.add('highlight');
foundCount++;
} else {
item.style.display = 'none';
item.classList.remove('highlight');
}
});
if (foundCount === 0) {
alert('검색 결과가 없습니다');
// 모든 항목 다시 표시
termItems.forEach(item => {
item.style.display = '';
item.classList.remove('highlight');
});
searchInput.value = '';
}
}
// Enter 키로 검색 실행
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('termSearchInput');
if (searchInput) {
searchInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') {
searchTerm();
}
});
// 입력값이 비워지면 전체 표시
searchInput.addEventListener('input', function() {
if (this.value.trim() === '') {
const termItems = document.querySelectorAll('.term-item');
termItems.forEach(item => {
item.style.display = '';
item.classList.remove('highlight');
});
}
});
}
});
// 녹음 상태 관리
let isRecording = true;
let timerInterval = null;
// 녹음 일시정지/재개 토글
function toggleRecording() {
const pauseIcon = document.getElementById('pauseIcon');
const recIcon = document.getElementById('recIcon');
const recordingIndicator = document.getElementById('recordingIndicator');
if (isRecording) {
// 일시정지
isRecording = false;
pauseIcon.style.display = 'none';
recIcon.style.display = 'block';
recordingIndicator.classList.add('paused');
// 타이머 정지
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
alert('녹음이 일시정지되었습니다'); alert('녹음이 일시정지되었습니다');
} else {
// 재개
isRecording = true;
pauseIcon.style.display = 'block';
recIcon.style.display = 'none';
recordingIndicator.classList.remove('paused');
// 타이머 재시작
startTimer();
alert('녹음이 재개되었습니다');
}
} }
// 회의 종료 // 회의 종료
@ -991,19 +1234,27 @@
alert('회의가 종료되었습니다. AI가 회의록을 생성 중입니다...'); alert('회의가 종료되었습니다. AI가 회의록을 생성 중입니다...');
setTimeout(() => { setTimeout(() => {
window.location.href = '10-회의록상세조회.html'; window.location.href = '07-회의종료.html';
}, 2000); }, 2000);
} }
} }
// 타이머 업데이트 // 타이머 시작 함수
function updateTimer() { function startTimer() {
const timerElement = document.getElementById('recordingTime'); const timerElement = document.getElementById('recordingTime');
let seconds = 51;
let minutes = 15;
let hours = 0;
setInterval(() => { // 이미 타이머가 실행 중이면 중복 실행 방지
if (timerInterval) {
return;
}
timerInterval = setInterval(() => {
// 현재 시간을 파싱
const currentTime = timerElement.textContent.split(':');
let hours = parseInt(currentTime[0]);
let minutes = parseInt(currentTime[1]);
let seconds = parseInt(currentTime[2]);
seconds++; seconds++;
if (seconds === 60) { if (seconds === 60) {
seconds = 0; seconds = 0;
@ -1023,131 +1274,28 @@
}, 1000); }, 1000);
} }
// SSE로 실시간 AI 제안사항 수신 // 타이머 초기화 함수
let eventSource = null; function updateTimer() {
const meetingId = '550e8400-e29b-41d4-a716-446655440000'; // 테스트용 회의 ID startTimer();
function connectAiSuggestionStream() {
// EventSource를 사용하여 SSE 연결
const apiUrl = `http://localhost:8083/api/suggestions/meetings/${meetingId}/stream`;
console.log('[DEBUG] SSE 연결 시작:', apiUrl);
eventSource = new EventSource(apiUrl);
// 연결 성공
eventSource.onopen = function(event) {
console.log('[SUCCESS] SSE 연결 성공!', event);
};
// 모든 이벤트 수신 (디버깅용)
eventSource.onmessage = function(event) {
console.log('[DEBUG] 일반 메시지 수신:', event.data);
};
// ai-suggestion 이벤트 수신
eventSource.addEventListener('ai-suggestion', function(event) {
console.log('[SUCCESS] AI 제안사항 수신:', event.data);
try {
const data = JSON.parse(event.data);
const suggestions = data.suggestions;
console.log('[DEBUG] 파싱된 데이터:', data);
console.log('[DEBUG] 제안사항 개수:', suggestions ? suggestions.length : 0);
if (suggestions && suggestions.length > 0) {
suggestions.forEach(suggestion => {
console.log('[DEBUG] 제안사항 추가 중:', suggestion);
addAiSuggestionToUI(suggestion);
});
} else {
console.warn('[WARNING] 제안사항이 비어있음');
}
} catch (error) {
console.error('[ERROR] AI 제안사항 파싱 오류:', error);
console.error('[ERROR] 원본 데이터:', event.data);
}
});
// 에러 처리
eventSource.onerror = function(error) {
console.error('[ERROR] SSE 연결 오류:', error);
console.error('[ERROR] ReadyState:', eventSource.readyState);
// ReadyState: 0=CONNECTING, 1=OPEN, 2=CLOSED
if (eventSource.readyState === EventSource.CLOSED) {
console.error('[ERROR] SSE 연결이 닫혔습니다');
} else if (eventSource.readyState === EventSource.CONNECTING) {
console.warn('[WARNING] SSE 재연결 시도 중...');
} }
// 에러 발생 시 닫기 // 관련 회의록 열기
eventSource.close(); function openRelatedDoc(docId) {
}; // 새 탭으로 회의록 상세조회 화면 열기
window.open('10-회의록상세조회.html', '_blank');
console.log('[INFO] AI 제안사항 SSE 스트림 연결 요청 완료'); // 기본 동작(링크 이동) 방지
return false;
} }
// AI 제안사항을 UI에 추가 // 용어 상세 정보 보기
function addAiSuggestionToUI(suggestion) { function showTermDetail(termName) {
const listContainer = document.getElementById('aiSuggestionList'); alert(`"${termName}" 용어에 대한 상세 정보를 표시합니다.`);
// 고유 ID 생성 (이미 추가된 제안인지 확인용)
const cardId = `suggestion-${suggestion.id}`;
// 이미 존재하는 제안이면 추가하지 않음
if (document.getElementById(cardId)) {
return;
} }
// AI 제안 카드 HTML 생성 // 페이지 로드 시 타이머 시작
const cardHtml = `
<div class="ai-suggestion-card" id="${cardId}">
<div class="ai-suggestion-header">
<span class="ai-suggestion-time">${suggestion.timestamp}</span>
<button class="ai-suggestion-add-btn"
onclick="addToMemo('${escapeHtml(suggestion.content)}', document.getElementById('${cardId}'))"
title="메모에 추가">
</button>
</div>
<div class="ai-suggestion-text">
${escapeHtml(suggestion.content)}
</div>
</div>
`;
// 리스트에 추가
listContainer.insertAdjacentHTML('beforeend', cardHtml);
console.log('AI 제안사항 추가됨:', suggestion.content);
}
// HTML 이스케이프 (XSS 방지)
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// 페이지 로드 시 타이머 시작 및 SSE 연결
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
updateTimer(); updateTimer();
connectAiSuggestionStream();
});
// 페이지 종료 시 SSE 연결 해제
window.addEventListener('beforeunload', function() {
if (eventSource) {
eventSource.close();
console.log('SSE 연결 종료');
}
}); });
</script> </script>
</body> </body>

View File

@ -1,527 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>검증 완료 - 회의록 서비스</title>
<link rel="stylesheet" href="common.css">
<style>
/* 페이지별 커스텀 스타일만 유지 */
/* 공통 스타일(헤더, 메인콘텐츠, 액션바)은 common.css 사용 */
.progress-container {
margin-bottom: var(--space-lg);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-sm);
}
.progress-percentage {
font-size: var(--font-h2);
font-weight: var(--font-weight-bold);
color: var(--primary);
}
.verification-card {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md);
background: var(--white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
margin-bottom: var(--space-md);
transition: all var(--transition-normal);
}
.verification-card:hover {
box-shadow: var(--shadow-lg);
}
.verification-card.verified {
border-left: 4px solid var(--success);
}
.verification-card.unverified {
border-left: 4px solid var(--gray-300);
}
.verification-card.locked {
background: var(--gray-100);
}
.verify-icon {
font-size: 32px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.verify-icon.verified {
color: var(--success);
}
.verify-icon.unverified {
color: var(--gray-300);
}
.lock-icon {
font-size: 20px;
color: var(--gray-500);
}
/* 편집 모드 스타일 */
.edit-field {
width: 100%;
padding: var(--space-sm);
border: 1px solid var(--gray-300);
border-radius: var(--radius-md);
font-size: var(--font-small);
line-height: 1.6;
margin-bottom: var(--space-sm);
transition: border-color var(--transition-normal);
}
.edit-field:focus {
outline: none;
border-color: var(--primary);
}
.edit-label {
display: block;
font-weight: var(--font-weight-medium);
margin-bottom: var(--space-xs);
color: var(--text-primary);
}
</style>
</head>
<body>
<div class="page">
<!-- Header -->
<header class="header">
<div class="header-left">
<button class="back-btn" onclick="history.back()"></button>
<h1 class="header-title">검증 완료</h1>
</div>
</header>
<!-- Main Content -->
<main class="main-content has-action-bar">
<!-- Progress Bar -->
<div class="progress-container">
<div class="progress-header">
<h2 class="text-small font-bold">전체 진행률</h2>
<span class="progress-percentage" id="progressText">50%</span>
</div>
<div class="progress" style="height: 12px;">
<div class="progress-bar progress-bar-success" id="progressBar" style="width: 50%;"></div>
</div>
<p class="text-small text-muted mt-sm">4개 섹션 중 2개 검증 완료</p>
</div>
<!-- Meeting Info -->
<div class="card mb-lg">
<div class="card-header">
<h3 class="card-title">2025년 1분기 제품 기획 회의</h3>
</div>
<div class="card-body">
<div class="text-small text-muted">
<div style="display: flex; gap: var(--space-md); margin-bottom: var(--space-xs);">
<span>📅 2025-10-25 14:00</span>
<span>⏱️ 90분</span>
</div>
<div>👥 김민준, 박서연, 이준호, 최유진</div>
</div>
</div>
</div>
<!-- Section List -->
<div>
<h2 class="text-small font-bold mb-md">섹션별 검증 상태</h2>
<!-- 섹션 1 - 검증 완료 -->
<div class="verification-card verified">
<div class="verify-icon verified"></div>
<div style="flex: 1;">
<h3 class="text-small font-bold" style="margin: 0 0 4px 0;">회의 개요</h3>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<div class="avatar-group">
<div class="avatar avatar-green avatar-sm"></div>
<div class="avatar avatar-blue avatar-sm"></div>
</div>
<span class="text-caption text-muted">2명 검증 완료</span>
</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="viewSection(0)">보기</button>
</div>
<!-- 섹션 2 - 검증 완료 + 잠금 -->
<div class="verification-card verified locked">
<div class="verify-icon verified"></div>
<div style="flex: 1;">
<h3 class="text-small font-bold" style="margin: 0 0 4px 0;">
논의 사항
<span class="lock-icon">🔒</span>
</h3>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<div class="avatar-group">
<div class="avatar avatar-green avatar-sm"></div>
<div class="avatar avatar-blue avatar-sm"></div>
<div class="avatar avatar-yellow avatar-sm"></div>
</div>
<span class="text-caption text-muted">3명 검증 완료 · 잠금됨</span>
</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="unlockSection(1)">잠금해제</button>
</div>
<!-- 섹션 3 - 미검증 -->
<div class="verification-card unverified">
<div class="verify-icon unverified"></div>
<div style="flex: 1;">
<h3 class="text-small font-bold" style="margin: 0 0 4px 0;">결정 사항</h3>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<div class="avatar-group">
<div class="avatar avatar-blue avatar-sm"></div>
</div>
<span class="text-caption text-muted">1명 검증 완료</span>
</div>
</div>
<button class="btn btn-primary btn-sm" onclick="verifySection(2)">검증하기</button>
</div>
<!-- 섹션 4 - 미검증 -->
<div class="verification-card unverified">
<div class="verify-icon unverified"></div>
<div style="flex: 1;">
<h3 class="text-small font-bold" style="margin: 0 0 4px 0;">액션 아이템</h3>
<div style="display: flex; align-items: center; gap: var(--space-sm);">
<span class="text-caption text-muted">아직 검증되지 않음</span>
</div>
</div>
<button class="btn btn-primary btn-sm" onclick="verifySection(3)">검증하기</button>
</div>
</div>
</main>
<!-- 하단 액션 바 -->
<div class="action-bar">
<button class="btn btn-secondary" onclick="saveLater()">나중에 하기</button>
<button class="btn btn-primary" id="completeBtn" disabled onclick="completeAllVerification()">
모두 검증 완료
</button>
</div>
</div>
<!-- Section View Modal -->
<div id="sectionModal" class="modal-overlay">
<div class="modal" style="max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h2 class="modal-title" id="sectionTitle">회의 개요</h2>
<button class="modal-close" onclick="closeModal('sectionModal')"></button>
</div>
<div class="modal-body">
<div id="sectionContent">
<!-- 섹션 내용이 여기에 표시됨 -->
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closeModal('sectionModal')">닫기</button>
<button class="btn btn-primary btn-sm" id="editBtn" onclick="toggleEditMode()">편집</button>
<button class="btn btn-primary btn-sm" id="saveBtn" style="display: none;" onclick="saveSection()">저장</button>
</div>
</div>
</div>
<!-- Verification Confirm Modal -->
<div id="verifyModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">섹션 검증</h2>
<button class="modal-close" onclick="closeModal('verifyModal')"></button>
</div>
<div class="modal-body">
<p class="text-small mb-md" id="verifyMessage">이 섹션의 내용을 검증하시겠습니까?</p>
<div class="card" style="background: var(--primary-light);">
<p class="text-small font-medium">검증 후에는 다른 참석자들도 이 섹션이 확인되었음을 알 수 있습니다.</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closeModal('verifyModal')">취소</button>
<button class="btn btn-primary btn-sm" onclick="confirmVerification()">검증 완료</button>
</div>
</div>
</div>
<!-- Unlock Confirm Modal -->
<div id="unlockModal" class="modal-overlay">
<div class="modal">
<div class="modal-header">
<h2 class="modal-title">섹션 잠금 해제</h2>
<button class="modal-close" onclick="closeModal('unlockModal')"></button>
</div>
<div class="modal-body">
<p class="text-small mb-md">이 섹션의 잠금을 해제하시겠습니까?</p>
<div class="card" style="background: transparent; color: var(--error); border: 1px solid var(--error);">
<p class="text-small font-medium">⚠️ 잠금 해제 시 다른 참석자들이 내용을 수정할 수 있습니다.</p>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-sm" style="background: transparent; color: var(--error); border: 1px solid var(--error);" onclick="closeModal('unlockModal')">취소</button>
<button class="btn btn-error btn-sm" onclick="confirmUnlock()">잠금 해제</button>
</div>
</div>
</div>
<script src="common.js"></script>
<script>
// 섹션 검증 상태
const sectionVerifications = [
{ name: '회의 개요', verified: true, locked: false, verifiers: ['김민준', '박서연'] },
{ name: '논의 사항', verified: true, locked: true, verifiers: ['김민준', '박서연', '이준호'] },
{ name: '결정 사항', verified: false, locked: false, verifiers: ['박서연'] },
{ name: '액션 아이템', verified: false, locked: false, verifiers: [] }
];
let currentSectionIndex = -1;
let isEditMode = false;
let originalContent = '';
// 섹션 데이터 (편집 가능한 필드)
const sectionData = [
{
purpose: '2025년 1분기 신제품 개발 방향 수립',
attendees: '김민준(PM), 박서연(AI), 이준호(Backend), 최유진(Frontend)',
datetime: '2025년 10월 25일 14:00 - 15:30',
location: '본사 2층 대회의실'
},
{
topic1: 'AI 모델 정확도',
detail1: '현재 STT 정확도: 92%, 목표 정확도: 95% 이상',
topic2: '사용자 인터페이스',
detail2: 'Mobile First 디자인 채택, 실시간 협업 기능 필수'
}
];
// 진행률 업데이트
function updateProgress() {
const totalSections = sectionVerifications.length;
const verifiedCount = sectionVerifications.filter(s => s.verified).length;
const percentage = Math.round((verifiedCount / totalSections) * 100);
$('#progressBar').style.width = percentage + '%';
$('#progressText').textContent = percentage + '%';
const completeBtn = $('#completeBtn');
if (percentage === 100) {
completeBtn.disabled = false;
completeBtn.style.opacity = '1';
} else {
completeBtn.disabled = true;
completeBtn.style.opacity = '0.5';
}
}
// 섹션 보기
function viewSection(index) {
currentSectionIndex = index;
isEditMode = false;
const section = sectionVerifications[index];
$('#sectionTitle').textContent = section.name;
// 편집 모드 초기화
$('#editBtn').style.display = 'inline-block';
$('#saveBtn').style.display = 'none';
// 회의 개요만 편집 가능
if (index === 0) {
renderSectionContent(index, false);
} else {
// 다른 섹션은 읽기 전용
const sampleContent = `
<p><strong>1. AI 모델 정확도</strong></p>
<p>- 현재 STT 정확도: 92%</p>
<p>- 목표 정확도: 95% 이상</p>
<br>
<p><strong>2. 사용자 인터페이스</strong></p>
<p>- Mobile First 디자인 채택</p>
<p>- 실시간 협업 기능 필수</p>
`;
$('#sectionContent').innerHTML = sampleContent;
}
openModal('sectionModal');
}
// 섹션 내용 렌더링
function renderSectionContent(index, editMode) {
const data = sectionData[index];
const contentDiv = $('#sectionContent');
if (editMode && index === 0) {
// 회의 개요 편집 모드
contentDiv.innerHTML = `
<div>
<label class="edit-label">회의 목적</label>
<input type="text" class="edit-field" id="edit_purpose" value="${data.purpose}">
</div>
<div>
<label class="edit-label">참석자</label>
<input type="text" class="edit-field" id="edit_attendees" value="${data.attendees}">
</div>
<div>
<label class="edit-label">일시</label>
<input type="text" class="edit-field" id="edit_datetime" value="${data.datetime}">
</div>
<div>
<label class="edit-label">장소</label>
<input type="text" class="edit-field" id="edit_location" value="${data.location}">
</div>
`;
} else if (index === 0) {
// 회의 개요 보기 모드
contentDiv.innerHTML = `
<p><strong>회의 목적:</strong> ${data.purpose}</p>
<p><strong>참석자:</strong> ${data.attendees}</p>
<p><strong>일시:</strong> ${data.datetime}</p>
<p><strong>장소:</strong> ${data.location}</p>
`;
}
}
// 섹션 검증
function verifySection(index) {
currentSectionIndex = index;
const section = sectionVerifications[index];
$('#verifyMessage').textContent = `"${section.name}" 섹션의 내용을 검증하시겠습니까?`;
openModal('verifyModal');
}
// 검증 확인
function confirmVerification() {
const section = sectionVerifications[currentSectionIndex];
section.verified = true;
if (!section.verifiers.includes('김민준')) {
section.verifiers.push('김민준');
}
closeModal('verifyModal');
showToast(`"${section.name}" 섹션이 검증되었습니다`, 'success');
// 화면 새로고침 시뮬레이션
setTimeout(() => {
location.reload();
}, 1000);
}
// 섹션 잠금 해제
function unlockSection(index) {
currentSectionIndex = index;
openModal('unlockModal');
}
// 잠금 해제 확인
function confirmUnlock() {
const section = sectionVerifications[currentSectionIndex];
section.locked = false;
closeModal('unlockModal');
showToast(`"${section.name}" 섹션의 잠금이 해제되었습니다`, 'success');
// 화면 새로고침 시뮬레이션
setTimeout(() => {
location.reload();
}, 1000);
}
// 편집 모드 토글
function toggleEditMode() {
if (currentSectionIndex !== 0) {
// 회의 개요가 아닌 경우
closeModal('sectionModal');
showToast('이 섹션은 회의록수정 화면에서 수정할 수 있습니다', 'info');
return;
}
isEditMode = !isEditMode;
if (isEditMode) {
// 편집 모드로 전환
renderSectionContent(currentSectionIndex, true);
$('#editBtn').style.display = 'none';
$('#saveBtn').style.display = 'inline-block';
} else {
// 보기 모드로 전환
renderSectionContent(currentSectionIndex, false);
$('#editBtn').style.display = 'inline-block';
$('#saveBtn').style.display = 'none';
}
}
// 섹션 저장
function saveSection() {
if (currentSectionIndex !== 0) return;
// 편집된 값 가져오기
const updatedData = {
purpose: $('#edit_purpose').value,
attendees: $('#edit_attendees').value,
datetime: $('#edit_datetime').value,
location: $('#edit_location').value
};
// 데이터 업데이트
sectionData[currentSectionIndex] = updatedData;
// 보기 모드로 전환
isEditMode = false;
renderSectionContent(currentSectionIndex, false);
$('#editBtn').style.display = 'inline-block';
$('#saveBtn').style.display = 'none';
// 성공 메시지
showToast('회의 개요가 저장되었습니다', 'success');
}
// 모두 검증 완료
function completeAllVerification() {
if (confirm('모든 섹션 검증을 완료하고 회의록을 확정하시겠습니까?')) {
showToast('회의록이 최종 확정되었습니다', 'success');
// 회의 종료 화면 또는 대시보드로 이동
setTimeout(() => {
alert('회의록이 확정되었습니다.\n참석자들에게 알림이 전송되었습니다.');
// navigateTo('01-대시보드.html');
}, 1500);
}
}
// 나중에 하기
function saveLater() {
if (confirm('검증을 나중에 완료하시겠습니까?\n회의록은 임시 저장됩니다.')) {
// 회의록 상태를 '작성중'으로 저장
// 실제로는 Meeting Service API 호출하여 임시 저장
showToast('회의록이 임시 저장되었습니다', 'info');
// 대시보드로 이동
setTimeout(() => {
navigateTo('02-대시보드.html');
}, 1000);
}
}
// 초기 진행률 업데이트
updateProgress();
</script>
</body>
</html>

View File

@ -25,92 +25,85 @@
color: var(--gray-700); color: var(--gray-700);
} }
/* 통계 카드 그리드 */ /* 통계 카드 그리드 - 10-회의록상세조회와 동일한 디자인 */
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: repeat(4, 1fr);
gap: var(--space-md); gap: var(--space-md);
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.stat-card { /* 모바일에서 gap 축소 */
background: var(--white); @media (max-width: 600px) {
padding: var(--space-md); .stats-grid {
border-radius: var(--radius-lg); gap: var(--space-xs);
box-shadow: var(--shadow-md); }
.stat-item {
padding: var(--space-sm);
}
.stat-value {
font-size: var(--font-base);
}
.stat-label {
font-size: var(--font-xs);
}
}
.stat-item {
text-align: center; text-align: center;
padding: var(--space-lg);
background: var(--gray-100);
border-radius: var(--radius-md);
} }
.stat-value { .stat-value {
font-size: var(--font-h1); font-size: var(--font-h1);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--primary); color: var(--primary);
margin-bottom: var(--space-xs); margin-bottom: var(--space-sm);
} }
.stat-label { .stat-label {
font-size: var(--font-small); font-size: var(--font-body);
color: var(--gray-500); color: var(--gray-600);
}
/* 키워드 클라우드 */
.keyword-cloud {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md);
}
.keyword-tag {
display: inline-block;
padding: 6px 12px;
background: var(--primary-light);
color: var(--primary-dark);
border-radius: 16px;
font-size: var(--font-small);
font-weight: var(--font-weight-medium); font-weight: var(--font-weight-medium);
} }
/* 발언 통계 바 차트 */ /* 키워드 섹션 - 10-회의록상세조회와 동일한 디자인 */
.speaker-stats { .keywords-section {
padding: var(--space-md) 0; margin-bottom: 0;
} }
.speaker-item { .keywords-title {
display: flex; font-size: var(--font-h4);
align-items: center; font-weight: var(--font-weight-bold);
gap: var(--space-md); color: var(--gray-900);
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.speaker-info { .keyword-tags {
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
gap: var(--space-sm); gap: var(--space-sm);
min-width: 120px; margin: var(--space-md) 0;
} }
.speaker-bar-container { .keyword-tag {
flex: 1; padding: 6px 12px;
height: 32px; background: var(--primary-light);
background: var(--gray-100); color: var(--primary);
border-radius: 16px; border-radius: 16px;
overflow: hidden; font-size: var(--font-small);
position: relative; cursor: pointer;
transition: all var(--transition-fast);
} }
.speaker-bar { .keyword-tag:hover {
height: 100%;
background: var(--primary); background: var(--primary);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: var(--space-sm);
color: var(--white); color: var(--white);
font-size: var(--font-caption);
font-weight: var(--font-weight-bold);
transition: width 1s ease;
} }
/* 안건 카드 */ /* 안건 카드 */
@ -182,99 +175,69 @@
} }
.agenda-section { .agenda-section {
margin-bottom: var(--space-md); margin-bottom: var(--space-lg);
padding-bottom: var(--space-md);
border-bottom: 1px solid var(--gray-200);
}
.agenda-section:last-child {
border-bottom: none;
padding-bottom: 0;
} }
.agenda-section-title { .agenda-section-title {
font-size: var(--font-small); font-size: var(--font-small);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--gray-500); color: var(--gray-900);
margin-bottom: var(--space-xs); margin-bottom: var(--space-sm);
text-transform: uppercase; display: flex;
align-items: center;
gap: var(--space-xs);
}
.agenda-section-title::before {
content: '';
display: inline-block;
width: 4px;
height: 16px;
background: var(--primary);
border-radius: 2px;
} }
.agenda-section-content { .agenda-section-content {
font-size: var(--font-body); font-size: var(--font-body);
color: var(--gray-700); color: var(--gray-700);
line-height: 1.6; line-height: 1.6;
padding-left: var(--space-md);
} }
/* Todo 리스트 (읽기 전용) */ .agenda-section-content ul {
.todo-list-item { margin: 0;
padding: var(--space-md); padding-left: var(--space-lg);
background: var(--gray-50);
border-radius: var(--radius-md);
margin-bottom: var(--space-sm);
opacity: 0.8;
} }
.todo-header { .agenda-section-content li {
display: flex; margin-bottom: var(--space-xs);
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-sm);
} }
.todo-checkbox { /* 하단 버튼 비율 조정 (1:2:1) */
margin-right: var(--space-sm); .action-bar .btn:nth-child(1) {
cursor: not-allowed;
}
.todo-content {
flex: 1;
font-weight: var(--font-weight-medium);
color: var(--gray-700);
}
.todo-meta {
display: flex;
align-items: center;
gap: var(--space-md);
font-size: var(--font-small);
color: var(--gray-500);
}
/* 하단 액션 바 - 3개 버튼 배치 */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--white);
padding: var(--space-md);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
display: flex;
gap: var(--space-sm);
z-index: 100;
}
.action-bar .btn {
flex: 1; flex: 1;
} }
.action-bar .btn-primary { .action-bar .btn:nth-child(2) {
flex: 2; /* 바로 최종 확정 버튼 강조 */ flex: 2;
} }
.action-bar .btn:nth-child(3) {
flex: 1;
}
/* 데스크톱 반응형 */
@media (min-width: 768px) { @media (min-width: 768px) {
.stats-grid { .stats-grid {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
} }
.action-bar {
position: static;
box-shadow: none;
justify-content: center;
gap: var(--space-md);
}
.action-bar .btn {
flex: 0 1 200px;
}
.action-bar .btn-primary {
flex: 0 1 250px;
}
} }
.readonly-notice { .readonly-notice {
@ -303,44 +266,42 @@
🔒 이 화면은 <strong>확인 전용</strong>입니다. 내용을 수정하려면 "회의록 수정" 버튼을 클릭하세요. 🔒 이 화면은 <strong>확인 전용</strong>입니다. 내용을 수정하려면 "회의록 수정" 버튼을 클릭하세요.
</div> </div>
<!-- 회의 통계 및 키워드 카드 -->
<div class="card mb-lg">
<!-- 통계 카드 그리드 --> <!-- 통계 카드 그리드 -->
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-item">
<div class="stat-value" id="durationValue">0</div> <div class="stat-value" id="participantsValue">4명</div>
<div class="stat-label">회의 시간 (분)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="participantsValue">0</div>
<div class="stat-label">참석자</div> <div class="stat-label">참석자</div>
</div> </div>
<div class="stat-card"> <div class="stat-item">
<div class="stat-value" id="agendasValue">0</div> <div class="stat-value" id="durationValue">90분</div>
<div class="stat-label">안건</div> <div class="stat-label">회의 시간</div>
</div> </div>
<div class="stat-card"> <div class="stat-item">
<div class="stat-value" id="todosValue">0</div> <div class="stat-value" id="agendasValue">3개</div>
<div class="stat-label">Todo</div> <div class="stat-label">주요 안건</div>
</div>
<div class="stat-item">
<div class="stat-value" id="todosValue">5개</div>
<div class="stat-label">Todo 생성</div>
</div> </div>
</div> </div>
<!-- 주요 키워드 --> <!-- 주요 키워드 -->
<div class="card mb-md"> <div class="keywords-section">
<h3 class="card-title">주요 키워드</h3> <h3 class="keywords-title">주요 키워드</h3>
<div class="keyword-cloud"> <div class="keyword-tags">
<span class="keyword-tag">신제품 기획</span> <span class="keyword-tag">#신제품기획</span>
<span class="keyword-tag">예산 편성</span> <span class="keyword-tag">#예산편성</span>
<span class="keyword-tag">일정 조율</span> <span class="keyword-tag">#일정조율</span>
<span class="keyword-tag">시장 조사</span> <span class="keyword-tag">#시장조사</span>
<span class="keyword-tag">UI/UX</span> <span class="keyword-tag">#UI/UX</span>
<span class="keyword-tag">개발 스펙</span> <span class="keyword-tag">#개발스펙</span>
</div>
</div> </div>
</div> </div>
<!-- 발언 통계 -->
<div class="card mb-md">
<h3 class="card-title">발언 통계</h3>
<div class="speaker-stats" id="speakerStats"></div>
</div>
<!-- 안건별 AI 요약 --> <!-- 안건별 AI 요약 -->
<div class="card mb-md"> <div class="card mb-md">
@ -352,7 +313,7 @@
<!-- 하단 액션 바 (3가지 선택 옵션) --> <!-- 하단 액션 바 (3가지 선택 옵션) -->
<div class="action-bar"> <div class="action-bar">
<button class="btn btn-ghost" onclick="navigateTo('02-대시보드.html')"> <button class="btn btn-neutral" onclick="navigateTo('02-대시보드.html')">
대시보드 대시보드
</button> </button>
<button class="btn btn-secondary" onclick="navigateTo('11-회의록수정.html')"> <button class="btn btn-secondary" onclick="navigateTo('11-회의록수정.html')">
@ -459,9 +420,6 @@
const totalTodos = SAMPLE_AGENDAS.reduce((sum, agenda) => sum + (agenda.todos?.length || 0), 0); const totalTodos = SAMPLE_AGENDAS.reduce((sum, agenda) => sum + (agenda.todos?.length || 0), 0);
animateCounter('todosValue', totalTodos); animateCounter('todosValue', totalTodos);
// 발언 통계 렌더링
renderSpeakerStats();
// 안건 리스트 렌더링 // 안건 리스트 렌더링
renderAgendaList(); renderAgendaList();
} }
@ -482,42 +440,6 @@
}, 30); }, 30);
} }
// 발언 통계 렌더링
function renderSpeakerStats() {
const stats = [
{ user: SAMPLE_MEETINGS[0].participants[0], count: 15, duration: 35 },
{ user: SAMPLE_MEETINGS[0].participants[1], count: 12, duration: 28 },
{ user: SAMPLE_MEETINGS[0].participants[2], count: 10, duration: 20 },
{ user: SAMPLE_MEETINGS[0].participants[3], count: 8, duration: 17 }
];
const maxDuration = Math.max(...stats.map(s => s.duration));
const container = $('#speakerStats');
stats.forEach(stat => {
const percentage = (stat.duration / maxDuration) * 100;
const item = createElement('div', { className: 'speaker-item' }, `
<div class="speaker-info">
${createAvatar(stat.user, 'sm')}
<span class="text-small">${stat.user.name}</span>
</div>
<div class="speaker-bar-container">
<div class="speaker-bar" style="width: 0%;" data-width="${percentage}">
${stat.duration}분
</div>
</div>
`);
container.appendChild(item);
});
// 애니메이션 시작
setTimeout(() => {
$$('.speaker-bar').forEach(bar => {
bar.style.width = bar.dataset.width + '%';
});
}, 100);
}
// 안건 리스트 렌더링 // 안건 리스트 렌더링
function renderAgendaList() { function renderAgendaList() {
const container = $('#agendaList'); const container = $('#agendaList');
@ -554,59 +476,85 @@
`)); `));
} }
// 발언자별 의견
if (agenda.details.opinions && agenda.details.opinions.length > 0) {
const opinionsHtml = agenda.details.opinions.map(op =>
`<li><strong>${op.speaker}:</strong> ${op.opinion}</li>`
).join('');
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">발언자별 의견</div>
<div class="agenda-section-content"><ul>${opinionsHtml}</ul></div>
`));
}
// 결정 사항 // 결정 사항
if (agenda.details.decisions && agenda.details.decisions.length > 0) { if (agenda.details.decisions && agenda.details.decisions.length > 0) {
const decisionsHtml = agenda.details.decisions.map(d => `<li>✓ ${d}</li>`).join(''); const decisionsSection = createElement('div', { className: 'agenda-section' }, `
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">결정 사항</div> <div class="agenda-section-title">결정 사항</div>
<div class="agenda-section-content"><ul>${decisionsHtml}</ul></div> `);
`));
const decisionsList = createElement('ul', {
className: 'agenda-section-content',
style: 'list-style: none; padding: 0; margin: 0; padding-left: var(--space-md);'
});
agenda.details.decisions.forEach(decision => {
const decisionItem = createElement('li', {
style: 'display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-xs) 0; font-size: var(--font-body); color: var(--gray-700);'
}, `
<span style="color: var(--gray-500); margin-top: 2px;"></span>
<span style="flex: 1;">${decision}</span>
`);
decisionsList.appendChild(decisionItem);
});
decisionsSection.appendChild(decisionsList);
content.appendChild(decisionsSection);
} }
// 보류 사항 // 보류 사항
if (agenda.details.pending && agenda.details.pending.length > 0) { if (agenda.details.pending && agenda.details.pending.length > 0) {
const pendingHtml = agenda.details.pending.map(p => `<li>⏸ ${p}</li>`).join(''); const pendingSection = createElement('div', { className: 'agenda-section' }, `
content.appendChild(createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">보류 사항</div> <div class="agenda-section-title">보류 사항</div>
<div class="agenda-section-content"><ul>${pendingHtml}</ul></div> `);
`));
const pendingList = createElement('ul', {
className: 'agenda-section-content',
style: 'list-style: none; padding: 0; margin: 0; padding-left: var(--space-md);'
});
agenda.details.pending.forEach(pending => {
const pendingItem = createElement('li', {
style: 'display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-xs) 0; font-size: var(--font-body); color: var(--gray-700);'
}, `
<span style="color: var(--gray-500); margin-top: 2px;"></span>
<span style="flex: 1;">${pending}</span>
`);
pendingList.appendChild(pendingItem);
});
pendingSection.appendChild(pendingList);
content.appendChild(pendingSection);
} }
// Todo 목록 // Todo 목록 - 제목만 간단히 표시
if (agenda.todos && agenda.todos.length > 0) { if (agenda.todos && agenda.todos.length > 0) {
const todosSection = createElement('div', { className: 'agenda-section' }, ` const todosSection = createElement('div', { className: 'agenda-section' }, `
<div class="agenda-section-title">Todo 자동 추출 결과</div> <div class="agenda-section-title">Todo 자동 추출 결과</div>
`); `);
agenda.todos.forEach(todo => { const todoList = createElement('ul', {
const todoItem = createElement('div', { className: 'todo-list-item' }, ` className: 'agenda-section-content',
<div class="todo-header"> style: 'list-style: none; padding: 0; margin: 0; padding-left: var(--space-md);'
<input type="checkbox" class="todo-checkbox" disabled>
<div class="todo-content">${todo.title}</div>
${createBadge(todo.priority === 'high' ? '높음' : todo.priority === 'medium' ? '보통' : '낮음',
`priority-${todo.priority}`)}
</div>
<div class="todo-meta">
${createAvatar(todo.assignee, 'sm')}
<span>${todo.assignee.name}</span>
<span></span>
<span>${formatDate(todo.dueDate)}</span>
</div>
`);
todosSection.appendChild(todoItem);
}); });
agenda.todos.forEach(todo => {
const todoItem = createElement('li', {
style: 'display: flex; align-items: flex-start; gap: var(--space-sm); padding: var(--space-xs) 0; font-size: var(--font-body); color: var(--gray-700);'
}, `
<span style="color: var(--gray-500); margin-top: 2px;"></span>
<span style="flex: 1;">${todo.title}</span>
`);
todoList.appendChild(todoItem);
});
todosSection.appendChild(todoList);
// 안내 문구 추가
const notice = createElement('p', {
style: 'font-size: var(--font-small); color: var(--gray-500); margin-top: var(--space-md); padding-top: var(--space-sm); border-top: 1px solid var(--gray-200);'
}, '💡 담당자 및 마감일은 회의록 수정 화면에서 지정할 수 있습니다.');
todosSection.appendChild(notice);
content.appendChild(todosSection); content.appendChild(todosSection);
} }

View File

@ -165,6 +165,23 @@
.participant { .participant {
width: calc(50% - var(--space-md) / 2); width: calc(50% - var(--space-md) / 2);
} }
/* 통계 그리드: 모바일에서도 4열 유지, gap만 축소 */
.stats-grid {
gap: var(--space-xs);
}
.stat-item {
padding: var(--space-sm);
}
.stat-value {
font-size: var(--font-base);
}
.stat-label {
font-size: var(--font-xs);
}
} }
/* 회의록 섹션 */ /* 회의록 섹션 */
@ -333,9 +350,6 @@
} }
/* 대시보드 탭 콘텐츠 */ /* 대시보드 탭 콘텐츠 */
.dashboard-section {
margin-bottom: var(--space-lg);
}
.key-points { .key-points {
list-style: none; list-style: none;
@ -393,7 +407,7 @@
.stats-grid { .stats-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(4, 1fr);
gap: var(--space-md); gap: var(--space-md);
margin-top: var(--space-md); margin-top: var(--space-md);
} }
@ -724,7 +738,11 @@
<!-- 기본 정보 카드 --> <!-- 기본 정보 카드 -->
<div class="info-card"> <div class="info-card">
<div class="meeting-basic-info"> <div class="meeting-basic-info">
<h2>2025년 1분기 제품 기획 회의</h2> <div id="meeting-title-container" style="display: flex; align-items: center; gap: var(--space-sm); margin-bottom: var(--space-sm);">
<span class="badge badge-complete">확정완료</span>
<!-- 생성자일 경우 👑 아이콘이 JavaScript로 추가됨 -->
<h2 style="margin: 0;">2025년 1분기 제품 기획 회의</h2>
</div>
<div class="info-row"> <div class="info-row">
<span class="info-icon">📅</span> <span class="info-icon">📅</span>
<span>2025년 10월 25일 14:00 (90분)</span> <span>2025년 10월 25일 14:00 (90분)</span>
@ -733,10 +751,6 @@
<span class="info-icon">📍</span> <span class="info-icon">📍</span>
<span>본사 2층 대회의실</span> <span>본사 2층 대회의실</span>
</div> </div>
<div class="info-row">
<span class="info-icon"></span>
<span class="badge badge-complete">확정완료</span>
</div>
</div> </div>
<div class="participants-list"> <div class="participants-list">
@ -951,10 +965,8 @@
<!-- 대시보드 탭 (기본 노출 탭 - 유저스토리 UFR-MEET-047 요구사항) --> <!-- 대시보드 탭 (기본 노출 탭 - 유저스토리 UFR-MEET-047 요구사항) -->
<div id="dashboard-content" class="tab-content active"> <div id="dashboard-content" class="tab-content active">
<!-- 핵심내용 --> <!-- 핵심내용 -->
<div class="section dashboard-section"> <div class="card mb-lg">
<div class="section-header"> <h3 class="card-title">💡 핵심내용</h3>
<h3 class="section-title">💡 핵심내용</h3>
</div>
<ol class="key-points"> <ol class="key-points">
<li class="key-point"> <li class="key-point">
@ -1004,10 +1016,8 @@
</div> </div>
<!-- 결정사항 --> <!-- 결정사항 -->
<div class="section dashboard-section"> <div class="card mb-lg">
<div class="section-header"> <h3 class="card-title">✅ 결정사항</h3>
<h3 class="section-title">✅ 결정사항</h3>
</div>
<div class="decision-card"> <div class="decision-card">
<div class="decision-content">베타 버전 출시일: 2025년 12월 1일</div> <div class="decision-content">베타 버전 출시일: 2025년 12월 1일</div>
@ -1033,10 +1043,8 @@
</div> </div>
<!-- Todo 진행상황 --> <!-- Todo 진행상황 -->
<div class="section dashboard-section"> <div class="card mb-lg">
<div class="section-header"> <h3 class="card-title">📋 Todo 진행상황</h3>
<h3 class="section-title">📋 Todo 진행상황</h3>
</div>
<!-- 전체 진행률 --> <!-- 전체 진행률 -->
<div style="margin-bottom: var(--space-lg);"> <div style="margin-bottom: var(--space-lg);">
@ -1182,10 +1190,8 @@
</div> </div>
<!-- 관련회의록 --> <!-- 관련회의록 -->
<div class="section dashboard-section"> <div class="card mb-lg">
<div class="section-header"> <h3 class="card-title">📚 관련회의록 (3건)</h3>
<h3 class="section-title">📚 관련회의록 (3건)</h3>
</div>
<div class="reference-item" onclick="window.open('10-회의록상세조회.html', '_blank')"> <div class="reference-item" onclick="window.open('10-회의록상세조회.html', '_blank')">
<div class="reference-header"> <div class="reference-header">
@ -1729,6 +1735,20 @@
* 페이지 초기화 * 페이지 초기화
*/ */
function initPage() { function initPage() {
// 회의 생성자 확인 후 👑 표시
const currentUser = '김민준'; // 현재 로그인 사용자
const isCreator = checkIfUserIsCreator(CURRENT_MEETING_ID, currentUser);
if (isCreator) {
const titleContainer = document.getElementById('meeting-title-container');
const badge = titleContainer.querySelector('.badge');
const crownIcon = document.createElement('span');
crownIcon.textContent = '👑';
crownIcon.style.fontSize = '24px';
// badge 다음에 👑 삽입
badge.insertAdjacentElement('afterend', crownIcon);
}
updateTodoProgress(); updateTodoProgress();
updateFilterCounts(); updateFilterCounts();
} }

View File

@ -604,7 +604,7 @@
const statusBadge = minute.status === 'complete' ? const statusBadge = minute.status === 'complete' ?
'<span class="badge badge-complete">확정완료</span>' : '<span class="badge badge-complete">확정완료</span>' :
'<span class="badge badge-draft">작성중</span>'; '<span class="badge badge-draft">작성중</span>';
const crownEmoji = isCreator ? '<span style="font-size: 16px; flex-shrink: 0;" title="생성자">👑</span>' : ''; const creatorBadge = isCreator ? '<span class="creator-badge" title="생성자">👑</span>' : '';
// 검증완료율 실시간 계산 (작성중 상태일 때만 표시) // 검증완료율 실시간 계산 (작성중 상태일 때만 표시)
const completionRate = minute.status === 'draft' const completionRate = minute.status === 'draft'
@ -615,7 +615,7 @@
<div class="meeting-item" data-status="${minute.status}" data-type="${participationType}" data-date="${minute.date}" onclick="navigateTo('10-회의록상세조회.html')"> <div class="meeting-item" data-status="${minute.status}" data-type="${participationType}" data-date="${minute.date}" onclick="navigateTo('10-회의록상세조회.html')">
<div class="meeting-header"> <div class="meeting-header">
${statusBadge} ${statusBadge}
${crownEmoji} ${creatorBadge}
<h3 class="meeting-title">${minute.title}</h3> <h3 class="meeting-title">${minute.title}</h3>
</div> </div>
<div class="meeting-meta"> <div class="meeting-meta">

View File

@ -179,6 +179,7 @@ a:hover {
font-size: var(--font-h3); font-size: var(--font-h3);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--gray-900); color: var(--gray-900);
margin-bottom: var(--space-md);
} }
.card-body { .card-body {
@ -258,6 +259,28 @@ a:hover {
background: var(--gray-100); background: var(--gray-100);
} }
/* Outline Button (회색 테두리) */
.btn-outline {
background: var(--white);
color: var(--gray-700);
border: 1px solid var(--gray-500);
}
.btn-outline:hover:not(:disabled) {
background: var(--gray-50);
border-color: var(--gray-600);
}
/* Neutral Button (진한 회색 배경) */
.btn-neutral {
background: #424242;
color: var(--white);
}
.btn-neutral:hover:not(:disabled) {
background: var(--gray-900);
}
/* Error Button */ /* Error Button */
.btn-error { .btn-error {
background: var(--error); background: var(--error);
@ -372,6 +395,21 @@ a:hover {
color: var(--gray-700); color: var(--gray-700);
} }
/* Creator Badge (생성자 표시) */
.creator-badge {
font-size: 16px;
flex-shrink: 0;
margin-left: 4px;
cursor: default;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.creator-badge[title] {
cursor: help;
}
/* Priority Badges */ /* Priority Badges */
.badge-high { .badge-high {
background: #FFEBEE; background: #FFEBEE;
@ -1238,9 +1276,9 @@ input[type="date"]::-webkit-calendar-picker-indicator {
} }
/* ======================================== /* ========================================
22. 섹션 카드 컴포넌트 22. 안건 카드 컴포넌트
======================================== */ ======================================== */
.section { .agenda {
background: var(--white); background: var(--white);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
@ -1248,14 +1286,14 @@ input[type="date"]::-webkit-calendar-picker-indicator {
margin-bottom: var(--space-lg); margin-bottom: var(--space-lg);
} }
.section-header { .agenda-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.section-title { .agenda-title {
font-size: var(--font-h3); font-size: var(--font-h3);
font-weight: var(--font-weight-bold); font-weight: var(--font-weight-bold);
color: var(--gray-900); color: var(--gray-900);
@ -1264,7 +1302,7 @@ input[type="date"]::-webkit-calendar-picker-indicator {
gap: var(--space-sm); gap: var(--space-sm);
} }
.section-content { .agenda-content {
color: var(--gray-700); color: var(--gray-700);
line-height: 1.6; line-height: 1.6;
} }

View File

@ -333,6 +333,41 @@
} }
``` ```
### 생성자 배지
회의 생성자를 나타내는 크라운 아이콘 배지입니다.
```css
/* 생성자 배지 (👑 아이콘) */
.creator-badge {
font-size: 16px;
flex-shrink: 0;
margin-left: 4px;
cursor: default;
display: inline-flex;
align-items: center;
vertical-align: middle;
}
/* 툴팁 제공 시 */
.creator-badge[title] {
cursor: help;
}
```
**사용 예시**:
```html
<div class="meeting-header">
<span class="badge badge-complete">확정완료</span>
<span class="creator-badge" title="생성자">👑</span>
<h3 class="meeting-title">2024년 4분기 제품 기획 회의</h3>
</div>
```
**사용 위치**:
- 12-회의록목록조회: 회의록 카드 헤더
- 02-대시보드: 최근 회의 카드, 내 회의록 카드
- 10-회의록상세조회: 회의록 정보 섹션
### D-day 배지 ### D-day 배지
Todo 마감일 표시를 위한 D-day 배지 스타일입니다. Todo 마감일 표시를 위한 D-day 배지 스타일입니다.

View File

@ -2,9 +2,9 @@
## 문서 정보 ## 문서 정보
- **작성일**: 2025-10-21 - **작성일**: 2025-10-21
- **최종 수정일**: 2025-10-24 - **최종 수정일**: 2025-10-25
- **작성자**: 이미준 (서비스 기획자) - **작성자**: 이미준 (서비스 기획자)
- **버전**: 1.4.16 - **버전**: 1.4.20
- **설계 철학**: Mobile First Design - **설계 철학**: Mobile First Design
--- ---
@ -628,51 +628,30 @@ graph TD
#### 개요 #### 개요
- **목적**: 실시간 회의 진행 및 AI 기반 회의록 자동 작성 - **목적**: 실시간 회의 진행 및 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) 1. 음성 녹음 및 실시간 텍스트 변환 (STT)
2. AI 자동 회의록 작성 (구조화) 2. AI 자동 회의록 작성 (구조화)
3. **AI 기반 회의 내용 요약 자동 생성** (섹션별) 3. **AI 기반 주요 메모 항목 실시간 제안** (UFR-MEET-030)
4. 실시간 협업 (여러 참석자 동시 편집) 4. 전문용어 자동 감지 및 맥락 기반 설명
5. 전문용어 자동 감지 및 맥락 기반 설명 5. **참고자료 자동 연결** (이전 회의록, 관련 회의록)
6. **참고자료 자동 연결** (이전 회의록, 관련 회의록) 6. 참석자 관리 및 초대 기능
7. 수동 편집 및 섹션별 작성 7. 회의 진행 시간 표시
8. 회의 진행 시간 표시
#### UI 구성요소 #### UI 구성요소
**전체 레이아웃 (2열 구조)** **전체 레이아웃**
- **헤더** (Fixed, 상단) - **헤더** (Fixed, 상단)
- 좌측: "회의 진행 중" 제목 + 경과시간 배지 (빨강, 01:03) - 좌측: "회의 진행 중" 제목 + 경과시간 배지 (빨강, 01:03)
- 우측: "회의 종료" 버튼 (민트 그린 테두리) - 우측: "회의 종료" 버튼 (민트 그린 테두리)
- **왼쪽 영역: 회의 내용 작성** (60-70% 너비) - **메인 콘텐츠 영역: 정보 패널** (탭 구조)
- **텍스트 에디터 툴바**
- B (Bold), I (Italic), U (Underline)
- 색상 선택, 링크 추가
- **편집 영역** (contentEditable, 스크롤 가능)
- 실시간 입력 텍스트: "회의 내용을 작성하거나 AI가 자동으로 작성합니다..."
- 섹션 구조:
```
# 참석자
- 김민준
- 박서연
- 이준호
# 안건
1. 신규 기능 개발 일정 논의
2. 예산 편성 검토
```
- 자동 저장 (30초 간격)
- **오른쪽 영역: 정보 패널** (30-40% 너비, 탭 구조)
- **탭 네비게이션** (4개 탭) - **탭 네비게이션** (4개 탭)
- 참석자 (3명) - 참석자 (3명)
- AI 제안 - AI 제안
@ -709,30 +688,6 @@ graph TD
- 본문 폰트: 14px, gray-700 - 본문 폰트: 14px, gray-700
- 구조: 헤더 + 본문 텍스트 + 액션 버튼 - 구조: 헤더 + 본문 텍스트 + 액션 버튼
- **논의사항 제안 카드**
- 헤더: "💬 논의사항 제안" (아이콘 + 제목, 16px bold, 민트 그린)
- 내용: "AI 모델 정확도 향상 방안" (strong 태그, 14px)
- 현재 STT 정확도: 92% (14px 일반, gray-700)
- 목표 정확도: 95% 이상
- 도메인 특화 학습 데이터 확보 필요
- 액션 버튼: "논의사항에 적용" (btn-primary btn-sm) + "수정" (btn-ghost btn-sm)
- **결정사항 제안 카드**
- 헤더: "✅ 결정사항 제안" (아이콘 + 제목, 16px bold, 민트 그린)
- 내용: "개발 일정 최종 확정" (strong 태그, 14px)
- 설계: 2주 (11/1~11/14) (14px 일반, gray-700)
- 개발: 10주 (11/15~1/23)
- 테스트 및 배포: 2주 (1/24~2/6)
- 액션 버튼: "결정사항에 적용" (btn-primary btn-sm) + "수정" (btn-ghost btn-sm)
- **액션아이템 제안 카드**
- 헤더: "📋 액션 아이템(Todo) 자동 추출" (아이콘 + 제목, 16px bold, 민트 그린)
- 추출된 Todo 목록 (14px 일반, gray-700):
1. API 명세서 작성 (이준호, 10/23까지)
2. UI 프로토타입 디자인 (최유진, 10/28까지)
3. AI 모델 성능 테스트 (박서연, 10/25까지)
- 액션 버튼: "3개 Todo 생성" (btn-primary btn-sm) + "수정" (btn-ghost btn-sm)
- **용어 사전 탭** - **용어 사전 탭**
- 제목: "용어 사전" - 제목: "용어 사전"
- 용어 검색 입력 필드 (placeholder: "용어 검색...") - 용어 검색 입력 필드 (placeholder: "용어 검색...")
@ -805,32 +760,22 @@ graph TD
- 카드 클릭 시: **새 탭으로 열기** (target="_blank") - 카드 클릭 시: **새 탭으로 열기** (target="_blank")
**Mobile (320px~768px)** **반응형 디자인**
- **2열 구조를 1열로 전환** - **Mobile (320px~768px)**
- 왼쪽 영역: 메인 콘텐츠 (전체 너비) - 헤더: 고정 상단, 좁은 너비
- 오른쪽 탭 패널: 하단 시트로 표시 - 메인 콘텐츠: 전체 너비 사용
- 탭 버튼 클릭 시 바텀시트 슬라이드업 - 탭 콘텐츠: 세로 스크롤
- 오버레이 + 닫기 버튼 - 하단 버튼 영역: 고정 하단
**Desktop (768px+)** - **Desktop (768px+)**
- 2열 고정 레이아웃 - 헤더: 고정 상단, 넓은 너비
- 왼쪽: 편집 영역 - 메인 콘텐츠: 최대 너비 제한 없이 반응형
- 오른쪽: 탭 패널 (고정) - 탭 콘텐츠: 더 넓은 영역 활용
- 하단 버튼 영역: 고정 하단
#### 인터랙션 #### 인터랙션
1. **텍스트 편집 (왼쪽 영역)**
- **편집 모드**: contentEditable 영역 클릭하여 즉시 편집 시작
- **자동 저장**: 편집 중 30초 간격 자동 저장
- **툴바 사용**:
- B (Bold): 선택된 텍스트를 굵게
- I (Italic): 선택된 텍스트를 이탤릭체로
- U (Underline): 선택된 텍스트에 밑줄
- 색상 선택: 텍스트 강조 색상 변경
- 링크 추가: URL 입력 모달 표시
- **실시간 동기화**: WebSocket 통해 모든 참석자에게 편집 내용 동기화
- **충돌 감지**: 동시 편집 시 충돌 감지 및 병합 옵션 제공
2. **탭 전환 (오른쪽 영역)** 1. **탭 전환**
- **참석자 탭**: 현재 회의 참석자 목록 표시 (4명) 및 참석자 추가 기능 - **참석자 탭**: 현재 회의 참석자 목록 표시 (4명) 및 참석자 추가 기능
- **참석자 추가 폼** (상단): - **참석자 추가 폼** (상단):
- 이메일 입력 필드 (form-control 스타일, placeholder: "이메일 주소 입력") - 이메일 입력 필드 (form-control 스타일, placeholder: "이메일 주소 입력")
@ -847,51 +792,28 @@ graph TD
- 상태 표시 없음 (발언 중/온라인 등 제거) - 상태 표시 없음 (발언 중/온라인 등 제거)
- 참석자 수 동적 업데이트 (초대 성공 시) - 참석자 수 동적 업데이트 (초대 성공 시)
- **AI 제안 탭**: AI가 생성한 회의록 개선 제안 (3가지 유형) - **AI 제안 탭**: AI가 생성한 주요 메모 항목 제안 (UFR-MEET-030)
- **실시간 주요 메모 추천**:
- **논의사항 제안 카드**: 제안 내용 + "논의사항에 적용" 버튼 - 음성→텍스트 변환 후 AI가 실시간으로 회의 내용 분석
- 제안 구조: - **중요한 내용으로 판단된 경우에만** 주요 메모 항목 제안
- 제목: "AI 모델 정확도 향상 방안" (strong) - 논의항목/결정사항 등의 구분 없이 중요 내용을 주요 메모로 제안
- 내용: 3-5개의 구체적인 논의 포인트 (bullet points) - 추천 빈도는 중요 내용 발생에 따라 가변적 (고정 간격 아님)
- "논의사항에 적용" 클릭 시: - 각 제안 항목에 "주요 메모에 추가" 버튼 제공
1. 논의사항 섹션(section-1)의 content-1 영역에 제안 내용 추가 - 클릭 시 해당 안건의 주요 메모에 자동 저장
2. 기존 내용 하단에 `<br>` 태그로 구분하여 추가 - 실시간 업데이트: 새로운 제안은 상단에 표시
3. 제목은 `<strong>` 태그, 내용은 `<p>` 태그로 구조화
4. 성공 토스트 표시: "논의사항에 AI 제안이 추가되었습니다"
5. 자동으로 논의사항 탭(섹션 1)으로 전환 (switchSection(1))
6. 제안 카드 숨김 처리 (display: none)
- "수정" 버튼: 제안을 거부하고 카드 숨김
- **결정사항 제안 카드**: 제안 내용 + "결정사항에 적용" 버튼
- 제안 구조:
- 제목: "개발 일정 최종 확정" (strong)
- 내용: 확정된 결정사항 (bullet points)
- "결정사항에 적용" 클릭 시:
1. 결정사항 섹션(section-2)의 content-2 영역에 제안 내용 추가
2. 기존 내용 하단에 `<br>` 태그로 구분하여 추가
3. 제목은 `<strong>✓` 접두어 포함, 내용은 `<p>` 태그로 구조화
4. 성공 토스트 표시: "결정사항에 AI 제안이 추가되었습니다"
5. 자동으로 결정사항 탭(섹션 2)으로 전환 (switchSection(2))
6. 제안 카드 숨김 처리 (display: none)
- "수정" 버튼: 제안을 거부하고 카드 숨김
- **액션아이템 제안 카드**: 제안 내용 + "3개 Todo 생성" 버튼
- 제안 구조:
- 헤더: "📋 액션 아이템(Todo) 자동 추출"
- 내용: 3개의 Todo 항목 (제목, 담당자, 마감일)
- "3개 Todo 생성" 클릭 시:
1. 액션아이템 섹션(section-3)의 content-3 영역에 Todo 항목 추가
2. **중복 체크**: 기존 Todo 목록에서 동일한 제목이 있는지 확인
3. 중복되지 않은 Todo만 추가 (Set 자료구조 활용)
4. Todo HTML 구조: checkbox + 제목 + 담당자/마감일 + 우선순위 배지
5. 성공 토스트 표시: "N개의 액션아이템이 추가되었습니다 (중복 제외)"
6. 중복된 항목이 있으면: "모든 항목이 이미 존재합니다" (info 토스트)
7. 자동으로 액션아이템 탭(섹션 3)으로 전환 (switchSection(3))
8. 제안 카드 숨김 처리 (display: none)
- "수정" 버튼: 제안을 거부하고 카드 숨김
- **용어 사전 탭**: 회의에서 언급된 전문용어 설명 - **용어 사전 탭**: 회의에서 언급된 전문용어 설명
- 용어 카드 (민트 그린 배경): 용어명 + 간단한 정의 - **용어 검색 기능**:
- 검색 입력창 (placeholder: "용어 검색...", form-control 스타일)
- 검색 버튼 (btn btn-primary btn-sm)
- Enter 키 지원
- 검색 동작:
1. 용어명과 정의 모두 검색
2. 일치하는 용어만 표시, 나머지는 숨김
3. 검색 결과에 하이라이트 효과 적용
4. 검색 결과 없으면 전체 목록 다시 표시
5. 입력창이 비어있으면 전체 목록 표시
- 용어 카드: 용어명 + 카테고리 배지 + 간단한 정의
- 카드 클릭 → 확장하여 상세 설명 표시 - 카드 클릭 → 확장하여 상세 설명 표시
- 상세 설명: 이 회의에서의 의미, 관련 회의록 링크 - 상세 설명: 이 회의에서의 의미, 관련 회의록 링크
@ -900,41 +822,42 @@ graph TD
- **녹음 중인 페이지 이탈 방지**: 모든 링크는 새 탭으로 열림 - **녹음 중인 페이지 이탈 방지**: 모든 링크는 새 탭으로 열림
- 관련도 표시: 퍼센트 또는 별점으로 시각화 - 관련도 표시: 퍼센트 또는 별점으로 시각화
3. **회의 종료** 2. **회의 종료**
- 헤더의 "회의 종료" 버튼 클릭 - 헤더의 "회의 종료" 버튼 클릭
- 확인 다이얼로그 표시: "회의를 종료하시겠습니까?" - 확인 다이얼로그 표시: "회의를 종료하시겠습니까?"
- 확인 → 회의 종료 처리 및 07-회의종료.html로 이동 - 확인 → 회의 종료 처리 및 07-회의종료.html로 이동
4. **실시간 업데이트** 3. **실시간 업데이트**
- STT 음성 인식 결과 실시간 반영 (3-5초 주기) - STT 음성 인식 결과 실시간 반영 (3-5초 주기)
- 모든 참석자의 편집 내용 실시간 동기화 - AI 제안 실시간 업데이트
- 수정 사항 하이라이트 표시 (3초간) - 용어 사전 자동 업데이트 (새로운 전문용어 감지 시)
- 관련 회의록 목록 동적 갱신
#### 데이터 요구사항 #### 데이터 요구사항
- **입력**: - **입력**:
- 회의 ID - 회의 ID
- 오디오 스트림 (실시간 STT용) - 오디오 스트림 (실시간 STT용)
- 사용자 편집 내용 (텍스트 입력) - 참석자 초대 이메일
- **출력**: - **출력**:
- 실시간 텍스트 변환 결과 (STT) - 실시간 텍스트 변환 결과 (STT)
- 편집된 회의록 내용 - **AI 제안 목록** (주요 메모 항목 제안)
- **AI 제안 목록** (회의록 개선 제안)
- **전문용어 및 설명** (용어 사전) - **전문용어 및 설명** (용어 사전)
- **관련 회의록 목록** (32건, 관련도 포함) - **관련 회의록 목록** (32건, 관련도 포함)
- 참석자 목록 및 상태 - 참석자 목록
- **연동**: - **연동**:
- STT 서비스 (UFR-AI-010) - STT 서비스 (UFR-AI-010)
- AI 서비스 (AI 제안 생성, UFR-AI-040) - AI 서비스 (주요 메모 제안 생성, UFR-AI-040)
- RAG 서비스 (관련 회의록 검색) - RAG 서비스 (관련 회의록 검색, 전문용어 자동 감지)
- Collaboration 서비스 (실시간 동기화) - Collaboration 서비스 (실시간 동기화)
#### 에러 처리 #### 에러 처리
- **마이크 권한 거부**: "마이크 권한이 필요합니다" 토스트 + 설정 안내 링크 - **마이크 권한 거부**: "마이크 권한이 필요합니다" 토스트 + 설정 안내 링크
- **STT 실패**: "음성 인식에 실패했습니다. 수동으로 입력해주세요" 토스트 - **STT 실패**: "음성 인식에 실패했습니다" 토스트 + 재시도 안내
- **AI 제안 생성 실패**: "AI 제안을 불러올 수 없습니다" 토스트 (편집은 계속 가능) - **AI 제안 생성 실패**: "AI 제안을 불러올 수 없습니다" 토스트
- **용어 사전 로드 실패**: "용어 사전을 불러올 수 없습니다" 메시지 표시
- **관련 자료 검색 실패**: "관련 회의록을 찾을 수 없습니다" 메시지 표시 - **관련 자료 검색 실패**: "관련 회의록을 찾을 수 없습니다" 메시지 표시
- **동기화 실패**: "네트워크 연결을 확인해주세요. 내용은 로컬에 저장됩니다" 토스트 - **참석자 초대 실패**: "초대 링크 전송에 실패했습니다" 토스트 + 재시도 버튼
- **편집 충돌**: "다른 참석자가 동일한 부분을 수정 중입니다" 다이얼로그 + 병합 옵션 - **동기화 실패**: "네트워크 연결을 확인해주세요" 토스트
- **회의 종료 실패**: "회의 종료 중 오류가 발생했습니다" 토스트 + 재시도 버튼 - **회의 종료 실패**: "회의 종료 중 오류가 발생했습니다" 토스트 + 재시도 버튼
--- ---
@ -976,7 +899,6 @@ graph TD
- 회의 총 시간 - 회의 총 시간
- 참석자 수 - 참석자 수
- 주요 키워드 (태그 클라우드) - 주요 키워드 (태그 클라우드)
- 발언 통계 (화자별 발언 횟수 및 시간 - 바 차트)
- **안건별 AI 요약 섹션** (신규) - **안건별 AI 요약 섹션** (신규)
- **안건 카드** (안건 개수만큼 반복): - **안건 카드** (안건 개수만큼 반복):
@ -986,7 +908,6 @@ graph TD
- 🔒 "편집 불가" 아이콘 표시 - 🔒 "편집 불가" 아이콘 표시
- 민트 그린 좌측 액센트 라인 - 민트 그린 좌측 액센트 라인
- **상세 요약 정리** (읽기 전용) - **상세 요약 정리** (읽기 전용)
- 논의 주제
- 발언자별 의견 - 발언자별 의견
- 결정 사항 - 결정 사항
- 보류 사항 - 보류 사항
@ -1034,15 +955,21 @@ graph TD
- 11-회의록수정.html로 이동 - 11-회의록수정.html로 이동
- URL 파라미터: meetingId - URL 파라미터: meetingId
- 회의록 상태: 작성중 - 회의록 상태: 작성중
- **옵션 2: 바로 최종 확정** - **옵션 2: 바로 최종 확정** (UFR-MEET-050 시나리오 2)
- 확인 다이얼로그 표시 - 확인 다이얼로그 표시: "바로 최종 확정하시겠습니까? AI가 정리한 내용 그대로 확정됩니다."
- 확인 시: - 확인 시:
- 모든 안건 검증률 100% 자동 설정 - 모든 안건 검증률 100% 자동 설정
- 회의록 상태: 확정완료 - 안건별 검증완료 처리
- 회의록 상태: "작성중" → "확정완료"로 변경
- 확정 시간 기록 - 확정 시간 기록
- 참석자에게 확정 알림 발송 - 참석자에게 확정 알림 발송
- 성공 토스트: "회의록이 최종 확정되었습니다" - 성공 토스트: "회의록이 최종 확정되었습니다"
- 02-대시보드.html로 이동 - 10-회의록상세조회.html로 이동
- **시나리오 2 특징 (바로 확정)**:
- 회의록 수정 단계를 건너뜀
- AI 생성 내용을 그대로 확정
- 모든 안건이 자동으로 검증완료 처리됨
- 확정 후에도 회의 생성자는 수정 가능 (잠금 해제 필요)
- **옵션 3: 대시보드로 이동** - **옵션 3: 대시보드로 이동**
- 회의록 상태: 작성중 - 회의록 상태: 작성중
- 02-대시보드.html로 이동 - 02-대시보드.html로 이동
@ -1237,9 +1164,9 @@ graph TD
#### 주요 기능 #### 주요 기능
1. 회의 기본 정보 표시 1. 회의 기본 정보 표시
2. **섹션별 AI 요약 표시** (섹션 최상단) 2. **안건별 AI 요약 표시** (안건 최상단)
3. 섹션별 상세 내용 표시 3. 안건별 상세 내용 표시
4. **참고자료 표시** (섹션 하단) 4. **참고자료 표시** (안건 하단)
5. Todo 항목 및 진행 상황 표시 5. Todo 항목 및 진행 상황 표시
6. 첨부파일 다운로드 6. 첨부파일 다운로드
7. 회의록 수정/공유 액션 7. 회의록 수정/공유 액션
@ -1263,17 +1190,17 @@ graph TD
- "대시보드" 탭 (기본 활성) - "대시보드" 탭 (기본 활성)
- "회의록" 탭 - "회의록" 탭
- **회의록 탭 콘텐츠** (섹션별 구조) - **회의록 탭 콘텐츠** (안건별 구조)
- 각 섹션: - 각 안건:
- 섹션 제목 - 안건 제목
- 검증 완료 배지 (검증된 경우) - 검증 완료 배지 (검증된 경우)
- **AI 회의 내용 요약 영역** (섹션 최상단, 강조 박스) - **AI 회의 내용 요약 영역** (안건 최상단, 강조 박스)
- 요약 아이콘 (💡) - 요약 아이콘 (💡)
- AI 자동 생성 요약 (2-3문장) - AI 자동 생성 요약 (2-3문장)
- 요약 생성/수정 시간 - 요약 생성/수정 시간
- "수정" 버튼 (권한 있는 경우) - "수정" 버튼 (권한 있는 경우)
- 섹션 내용 (마크다운 렌더링) - 안건 내용 (마크다운 렌더링)
- **참고자료 영역** (섹션 하단, 별도 영역) - **참고자료 영역** (안건 하단, 별도 영역)
- "참고자료" 라벨 - "참고자료" 라벨
- 관련 회의록 링크 리스트 (최대 3개): - 관련 회의록 링크 리스트 (최대 3개):
- 링크 아이콘 (📄) - 링크 아이콘 (📄)
@ -1331,17 +1258,17 @@ graph TD
- 대시보드 (기본 활성) - 대시보드 (기본 활성)
- 회의록 - 회의록
- **메인 영역**: - **메인 영역**:
- 회의록 탭: 전체 회의록 내용 (섹션별 구조) - 회의록 탭: 전체 회의록 내용 (안건별 구조)
- 대시보드 탭: 핵심내용, 결정사항, Todo 진행상황, 참고자료 (11-회의록대시보드.html 구조 참조) - 대시보드 탭: 핵심내용, 결정사항, Todo 진행상황, 참고자료 (11-회의록대시보드.html 구조 참조)
#### 인터랙션 #### 인터랙션
1. **탭 전환** 1. **탭 전환**
- "회의록" 탭: 전체 회의록 내용 표시 (섹션별 구조) - "회의록" 탭: 전체 회의록 내용 표시 (안건별 구조)
- "대시보드" 탭: 핵심내용, 결정사항, Todo, 참고자료 요약 표시 - "대시보드" 탭: 핵심내용, 결정사항, Todo, 참고자료 요약 표시
- 탭 전환 시 URL 변경 없이 클라이언트 사이드 렌더링 - 탭 전환 시 URL 변경 없이 클라이언트 사이드 렌더링
2. **회의록 탭 인터랙션** 2. **회의록 탭 인터랙션**
- **섹션 네비게이션**: 섹션 제목 클릭 → 해당 섹션으로 스크롤 - **안건 네비게이션**: 안건 제목 클릭 → 해당 안건으로 스크롤
- **접기/펼치기**: 긴 내용은 초기 접힌 상태, 클릭으로 펼침 - **접기/펼치기**: 긴 내용은 초기 접힌 상태, 클릭으로 펼침
- **AI 요약 편집**: - **AI 요약 편집**:
- "수정" 버튼 클릭 (권한 있는 경우) → 인라인 편집 모드 - "수정" 버튼 클릭 (권한 있는 경우) → 인라인 편집 모드
@ -1354,10 +1281,10 @@ graph TD
3. **대시보드 탭 인터랙션** 3. **대시보드 탭 인터랙션**
- **핵심내용 섹션**: - **핵심내용 섹션**:
- 키워드 태그 클릭 → 해당 키워드 관련 섹션으로 스크롤 - 키워드 태그 클릭 → 해당 키워드 관련 안건으로 스크롤
- 통계 항목 클릭 → 상세 정보 툴팁 표시 - 통계 항목 클릭 → 상세 정보 툴팁 표시
- **결정사항 섹션**: - **결정사항 섹션**:
- 결정사항 카드 클릭 → 회의록 탭의 해당 섹션으로 이동 - 결정사항 카드 클릭 → 회의록 탭의 해당 안건으로 이동
- 배경 설명 접기/펼치기 - 배경 설명 접기/펼치기
- **Todo 진행상황**: - **Todo 진행상황**:
- 필터 탭 클릭 → 해당 상태의 Todo만 표시 - 필터 탭 클릭 → 해당 상태의 Todo만 표시
@ -1384,8 +1311,8 @@ graph TD
- **입력**: 회의록 ID, 활성 탭 (회의록/대시보드/타임라인) - **입력**: 회의록 ID, 활성 탭 (회의록/대시보드/타임라인)
- **출력**: - **출력**:
- **회의 기본 정보**: 제목, 일시, 참석자, 장소, 상태, 작성자, 수정 시간 - **회의 기본 정보**: 제목, 일시, 참석자, 장소, 상태, 작성자, 수정 시간
- **섹션별 AI 요약**: 자동 생성 요약, 수정 이력 - **안건별 AI 요약**: 자동 생성 요약, 수정 이력
- **섹션별 내용**: 마크다운 형식 - **안건별 내용**: 마크다운 형식
- **참고자료 목록**: - **참고자료 목록**:
- 관련 회의록 (제목, 날짜, 관련도, 요약) - 관련 회의록 (제목, 날짜, 관련도, 요약)
- 프로젝트 문서 (제목, 작성자, 관련도) - 프로젝트 문서 (제목, 작성자, 관련도)
@ -1403,7 +1330,7 @@ graph TD
#### 에러 처리 #### 에러 처리
- **회의록 로딩 실패**: "회의록을 불러올 수 없습니다" + 재시도 버튼 - **회의록 로딩 실패**: "회의록을 불러올 수 없습니다" + 재시도 버튼
- **AI 요약 로딩 실패**: "요약을 불러올 수 없습니다" (섹션 내용은 정상 표시) - **AI 요약 로딩 실패**: "요약을 불러올 수 없습니다" (안건 내용은 정상 표시)
- **참고자료 로딩 실패**: "참고자료를 불러올 수 없습니다" (빈 상태 표시) - **참고자료 로딩 실패**: "참고자료를 불러올 수 없습니다" (빈 상태 표시)
- **대시보드 데이터 로딩 실패**: "대시보드를 불러올 수 없습니다" + 재시도 버튼 - **대시보드 데이터 로딩 실패**: "대시보드를 불러올 수 없습니다" + 재시도 버튼
- **권한 없음**: "수정" 버튼 비활성화, "조회 권한만 있습니다" 메시지 - **권한 없음**: "수정" 버튼 비활성화, "조회 권한만 있습니다" 메시지
@ -1468,6 +1395,9 @@ graph TD
- **안건 헤더** - **안건 헤더**
- 안건 제목 (H4, Bold) - 안건 제목 (H4, Bold)
- 검증 상태 배지 (검증완료/미검증) - 검증 상태 배지 (검증완료/미검증)
- 편집 중 표시 (동시 편집 시)
- 다른 사용자 아바타 + 이름
- 예: "김민준님 편집 중" (아이콘 + 텍스트)
- **AI 한줄 요약** (편집 불가, UFR-AI-036) - 신규 - **AI 한줄 요약** (편집 불가, UFR-AI-036) - 신규
- 🔒 아이콘 + 30자 이내 한줄 요약 - 🔒 아이콘 + 30자 이내 한줄 요약
- 읽기 전용 (회색 배경, 민트 그린 좌측 액센트 라인) - 읽기 전용 (회색 배경, 민트 그린 좌측 액센트 라인)
@ -1599,6 +1529,38 @@ graph TD
- 확정완료 회의록 수정 시: 자동으로 "작성중" 상태로 변경 - 확정완료 회의록 수정 시: 자동으로 "작성중" 상태로 변경
- 모든 안건 검증 완료 시: "확정완료"로 변경 제안 - 모든 안건 검증 완료 시: "확정완료"로 변경 제안
9. **안건 기반 충돌 해결 (UFR-COLLAB-020)**
- **안건 기반 충돌 방지 메커니즘**:
- **다른 안건 동시 편집**: 충돌 없음
- 참석자 A가 안건 1 편집 중
- 참석자 B가 안건 2 편집 가능
- 양쪽 모두 정상 저장 및 동기화
- **동일 안건 내 다른 필드 편집**: 자동 병합
- 참석자 A가 안건 1의 "상세 요약" 편집
- 참석자 B가 안건 1의 "관련회의록" 편집
- 양쪽 변경 사항 자동 병합
- **동일 필드 동시 수정**: Last Write Wins
- 마지막에 저장된 변경 사항이 적용
- 덮어쓰기 경고: "다른 사용자가 이미 수정했습니다. 최신 내용을 확인하세요"
- 선택 옵션: 최신 내용 확인 / 내 변경 사항 유지
- **편집 중 표시**:
- 다른 사용자가 편집 중인 안건 표시
- 편집자 아바타 + 이름 실시간 표시
- 예: "김민준님이 이 안건을 편집 중입니다" + 아바타
- 편집 시작 시 해당 안건에 브로드캐스트
- 편집 종료 시 표시 제거
- **충돌 경고 모달**:
- 제목: "동시 수정 감지"
- 메시지: "다른 사용자가 이미 이 내용을 수정했습니다"
- 옵션 버튼:
- "최신 내용 보기" (Primary): 다른 사용자 변경사항 로드
- "내 변경사항 유지" (Secondary): 현재 내용 유지 (덮어쓰기)
#### 데이터 요구사항 #### 데이터 요구사항
- **입력**: - **입력**:
- 회의록 ID (조회) - 회의록 ID (조회)
@ -1621,7 +1583,11 @@ graph TD
- **자동 저장 실패**: "네트워크 연결을 확인해주세요. 로컬에 임시 저장됩니다" - **자동 저장 실패**: "네트워크 연결을 확인해주세요. 로컬에 임시 저장됩니다"
- **AI 요약 재생성 실패**: "요약 생성에 실패했습니다. 수동으로 작성해주세요" - **AI 요약 재생성 실패**: "요약 생성에 실패했습니다. 수동으로 작성해주세요"
- **참고자료 검색 실패**: "회의록을 검색할 수 없습니다" - **참고자료 검색 실패**: "회의록을 검색할 수 없습니다"
- **충돌 발생**: "다른 참석자가 동일한 부분을 수정했습니다" + 병합 옵션 - **충돌 발생**:
- 안건 기반 충돌 방지로 최소화
- 동일 필드 동시 수정 시: "다른 사용자가 이미 수정했습니다" 경고 모달
- 선택 옵션: 최신 내용 확인 / 내 변경사항 유지
- 병합 실패 시: "병합 중 오류가 발생했습니다" 에러 메시지
- **삭제 실패**: "회의록 삭제에 실패했습니다" - **삭제 실패**: "회의록 삭제에 실패했습니다"
--- ---
@ -1708,8 +1674,10 @@ graph TD
- 각 회의록 항목 (meeting-item): - 각 회의록 항목 (meeting-item):
- **좌측 영역**: - **좌측 영역**:
- 회의 제목 (H5, 볼드) - 회의 제목 (H5, 볼드)
- **생성자 표시**: 현재 사용자가 회의 생성자인 경우 👑 아이콘 표시 (16px, title="생성자")
- 메타정보 (Caption, 회색): - 메타정보 (Caption, 회색):
- 회의 일시 (날짜 + 시간) · 참석자 수 - 회의 일시 (날짜 + 시간) · 참석자 수
- 검증완료율 (작성중 상태일 때만): "✓ {completionRate}% 검증완료" 배지
- 최종 수정 시간 (Caption, 회색): - 최종 수정 시간 (Caption, 회색):
- 상대 시간 표시 ("1시간 전", "어제", "3일 전") - 상대 시간 표시 ("1시간 전", "어제", "3일 전")
- **우측 영역**: - **우측 영역**:
@ -2100,6 +2068,7 @@ graph TD
| 버전 | 날짜 | 작성자 | 변경 내용 | | 버전 | 날짜 | 작성자 | 변경 내용 |
|------|------|--------|----------| |------|------|--------|----------|
| 1.4.20 | 2025-10-25 | 이미준, 강지수 | 유저스토리 v2.3.0 반영<br>- 회의 종료 화면 정책 명확화 (확인 전용, 바로 최종 확정 옵션 상세화)<br>- UFR-MEET-050: 최종 확정 2가지 시나리오 설명 추가<br>- UFR-COLLAB-020: 안건 기반 충돌 해결 메커니즘 상세 추가<br>- 실시간 협업 충돌 방지 정책 강화 |
| 1.0 | 2025-10-21 | 이미준 | 최초 작성 - 11개 화면 설계 완료 | | 1.0 | 2025-10-21 | 이미준 | 최초 작성 - 11개 화면 설계 완료 |
| 1.1 | 2025-10-21 | 이미준 | AI 요약 및 참고자료 기능 추가<br>- 05-회의진행: AI 회의 내용 요약 자동 생성 및 참고자료 자동 연결 추가<br>- 10-회의록상세조회: 섹션별 AI 요약 표시 및 참고자료 영역 추가<br>- 11-회의록수정: AI 요약 수정 및 참고자료 편집 기능 추가<br>- 관련 유저스토리: UFR-AI-040 (관련 회의록 자동 연결) | | 1.1 | 2025-10-21 | 이미준 | AI 요약 및 참고자료 기능 추가<br>- 05-회의진행: AI 회의 내용 요약 자동 생성 및 참고자료 자동 연결 추가<br>- 10-회의록상세조회: 섹션별 AI 요약 표시 및 참고자료 영역 추가<br>- 11-회의록수정: AI 요약 수정 및 참고자료 편집 기능 추가<br>- 관련 유저스토리: UFR-AI-040 (관련 회의록 자동 연결) |
| 1.1.1 | 2025-10-21 | 이미준 | 회의록 상세 화면 구조 개선 (프로토타입 기반)<br>- 10-회의록상세조회: 탭 기반 네비게이션 추가 (회의록/대시보드)<br>- 대시보드 탭 추가: 핵심내용, 결정사항, Todo 진행상황, 참고자료 섹션<br>- 참고자료 관련도 점수 표시 (백분율 + 색상 코딩)<br>- 참고자료 카테고리 탭 (관련 회의록/프로젝트 문서/이슈 트래커/위키 페이지)<br>- 참조: design-gappa/uiux/prototype 파일 (11-회의록대시보드.html, 05-회의진행.html) | | 1.1.1 | 2025-10-21 | 이미준 | 회의록 상세 화면 구조 개선 (프로토타입 기반)<br>- 10-회의록상세조회: 탭 기반 네비게이션 추가 (회의록/대시보드)<br>- 대시보드 탭 추가: 핵심내용, 결정사항, Todo 진행상황, 참고자료 섹션<br>- 참고자료 관련도 점수 표시 (백분율 + 색상 코딩)<br>- 참고자료 카테고리 탭 (관련 회의록/프로젝트 문서/이슈 트래커/위키 페이지)<br>- 참조: design-gappa/uiux/prototype 파일 (11-회의록대시보드.html, 05-회의진행.html) |
@ -2130,6 +2099,10 @@ graph TD
| 1.4.14 | 2025-10-24 | 이미준 | 12-회의록목록조회 화면 데이터 아키텍처 문서화<br>- **데이터 아키텍처 섹션 추가**: 데이터/뷰 레이어 분리 구조 설명<br> - 데이터 레이어: common.js → SAMPLE_MINUTES 배열 (30개 샘플)<br> - 뷰 레이어: 12-회의록목록조회.html → renderMeetings(), createMeetingCard() 함수<br> - 렌더링 방식: 동적 렌더링, 초기 10개 표시, "10개 더보기" 버튼으로 추가 로딩<br>- **정렬 옵션 레이블 변경**: "최신순" → "최근수정순", "회의일시순" → "최근회의순"<br>- **페이지네이션 기능 문서화**: 초기 10개 표시, "10개 더보기" 버튼 기능 설명<br>- **샘플 데이터 분포 명시**: 총 30개 (작성중 13개, 확정완료 17개)<br>- **프로토타입 파일 경로 추가**: design/uiux/prototype/12-회의록목록조회.html<br>- **스타일 가이드 버전 동기화**: v1.2.4 | | 1.4.14 | 2025-10-24 | 이미준 | 12-회의록목록조회 화면 데이터 아키텍처 문서화<br>- **데이터 아키텍처 섹션 추가**: 데이터/뷰 레이어 분리 구조 설명<br> - 데이터 레이어: common.js → SAMPLE_MINUTES 배열 (30개 샘플)<br> - 뷰 레이어: 12-회의록목록조회.html → renderMeetings(), createMeetingCard() 함수<br> - 렌더링 방식: 동적 렌더링, 초기 10개 표시, "10개 더보기" 버튼으로 추가 로딩<br>- **정렬 옵션 레이블 변경**: "최신순" → "최근수정순", "회의일시순" → "최근회의순"<br>- **페이지네이션 기능 문서화**: 초기 10개 표시, "10개 더보기" 버튼 기능 설명<br>- **샘플 데이터 분포 명시**: 총 30개 (작성중 13개, 확정완료 17개)<br>- **프로토타입 파일 경로 추가**: design/uiux/prototype/12-회의록목록조회.html<br>- **스타일 가이드 버전 동기화**: v1.2.4 |
| 1.4.15 | 2025-10-24 | 이미준 | 06-검증완료 화면 삭제 (유저스토리 v2.1.2 변경사항 반영)<br>- **화면 삭제**: 06-검증완료 화면 전체 삭제<br> - 안건별 검증 기능이 11-회의록수정 화면으로 통합됨<br> - 섹션별 검증 방식에서 안건별 검증 방식으로 변경 (유저스토리 UFR-COLLAB-030 → 안건 기반 구조로 전환)<br>- **유저스토리 매핑 업데이트**:<br> - Collaboration 서비스: UFR-COLLAB-010 ~ UFR-COLLAB-030 → UFR-COLLAB-010 ~ UFR-COLLAB-020로 변경<br> - 프로토타입 화면 목록 테이블에서 06-검증완료 행 제거<br>- **화면 번호 유지**: 다른 화면 번호는 변경하지 않음 (프로토타입 파일명 유지)<br> - 07-회의종료, 09-Todo관리, 10-회의록상세조회, 11-회의록수정, 12-회의록목록조회 번호 유지<br>- **변경 이력**: 과거 버전의 UFR-COLLAB-030 언급은 역사적 맥락으로 유지 | | 1.4.15 | 2025-10-24 | 이미준 | 06-검증완료 화면 삭제 (유저스토리 v2.1.2 변경사항 반영)<br>- **화면 삭제**: 06-검증완료 화면 전체 삭제<br> - 안건별 검증 기능이 11-회의록수정 화면으로 통합됨<br> - 섹션별 검증 방식에서 안건별 검증 방식으로 변경 (유저스토리 UFR-COLLAB-030 → 안건 기반 구조로 전환)<br>- **유저스토리 매핑 업데이트**:<br> - Collaboration 서비스: UFR-COLLAB-010 ~ UFR-COLLAB-030 → UFR-COLLAB-010 ~ UFR-COLLAB-020로 변경<br> - 프로토타입 화면 목록 테이블에서 06-검증완료 행 제거<br>- **화면 번호 유지**: 다른 화면 번호는 변경하지 않음 (프로토타입 파일명 유지)<br> - 07-회의종료, 09-Todo관리, 10-회의록상세조회, 11-회의록수정, 12-회의록목록조회 번호 유지<br>- **변경 이력**: 과거 버전의 UFR-COLLAB-030 언급은 역사적 맥락으로 유지 |
| 1.4.16 | 2025-10-24 | 이미준 | 사용자 역할 용어 통일 (유저스토리 v2.1.2 반영)<br>- **용어 정의 명확화**: "회의 생성자"와 "회의 참석자" 용어로 통일<br> - 설계 목표: "회의록 작성자" → "회의 참석자"로 수정<br>- **화면별 권한 정보 추가**:<br> - 03-회의예약: 모든 사용자 (예약 생성 시 자동으로 회의 생성자가 됨)<br> - 04-템플릿선택: 회의 생성자 전용<br> - 05-회의진행: 회의 시작/종료는 회의 생성자 전용, 회의록 편집은 모든 참석자<br> - 07-회의종료: 회의 생성자 전용<br> - 09-Todo관리: 모든 회의 참석자 (본인이 담당자인 Todo만 조회/수정 가능)<br> - 10-회의록상세조회: 모든 회의 참석자 (조회 전용)<br> - 11-회의록수정: 검증완료 전(모든 참석자), 검증완료 후(회의 생성자만) - 기존 권한 제어 유지<br> - 12-회의록목록조회: 모든 회의 참석자 (본인이 참석한 회의록만 조회)<br>- **스타일 가이드 동기화**: design/uiux/style-guide.md v1.2.5 (용어 정의 섹션 추가)<br>- **통일성 달성**: 유저스토리, 화면설계서, 스타일 가이드 간 용어 완전 통일 | | 1.4.16 | 2025-10-24 | 이미준 | 사용자 역할 용어 통일 (유저스토리 v2.1.2 반영)<br>- **용어 정의 명확화**: "회의 생성자"와 "회의 참석자" 용어로 통일<br> - 설계 목표: "회의록 작성자" → "회의 참석자"로 수정<br>- **화면별 권한 정보 추가**:<br> - 03-회의예약: 모든 사용자 (예약 생성 시 자동으로 회의 생성자가 됨)<br> - 04-템플릿선택: 회의 생성자 전용<br> - 05-회의진행: 회의 시작/종료는 회의 생성자 전용, 회의록 편집은 모든 참석자<br> - 07-회의종료: 회의 생성자 전용<br> - 09-Todo관리: 모든 회의 참석자 (본인이 담당자인 Todo만 조회/수정 가능)<br> - 10-회의록상세조회: 모든 회의 참석자 (조회 전용)<br> - 11-회의록수정: 검증완료 전(모든 참석자), 검증완료 후(회의 생성자만) - 기존 권한 제어 유지<br> - 12-회의록목록조회: 모든 회의 참석자 (본인이 참석한 회의록만 조회)<br>- **스타일 가이드 동기화**: design/uiux/style-guide.md v1.2.5 (용어 정의 섹션 추가)<br>- **통일성 달성**: 유저스토리, 화면설계서, 스타일 가이드 간 용어 완전 통일 |
|| 1.4.17 | 2025-10-24 | 강지수 | 07-회의종료 화면 STT 한계 반영 (유저스토리 v2.1.2)<br>- **STT 화자 식별 불가 반영**: STT는 화자를 식별할 수 없으므로 화자 관련 기능 제거<br> - 발언 통계 섹션 삭제<br> - 안건별 "발언자별 의견" 섹션 삭제<br>- **통계 영역 디자인 개선**: 정보성 디자인으로 명확화<br> - 배경색: var(--white) → var(--gray-50)<br> - 숫자 색상: var(--primary) → var(--gray-900)<br> - 라벨 색상: var(--gray-500) → var(--gray-600)<br> - 정보 표시 전용으로 시각적 구분 명확화<br>- **안건 섹션 구분 개선**:<br> - 안건 간 하단 보더 추가 (1px solid var(--gray-200))<br> - 섹션 제목에 primary 색상 세로 바 추가 (::before pseudo-element)<br> - 콘텐츠 영역 좌측 패딩 추가로 계층 구조 명확화<br>- **연관 문서 업데이트**:<br> - 유저스토리 UFR-MEET-040: "발언 횟수 (화자별)" 항목 제거<br> - UI/UX 설계서 07-회의종료: 발언 통계 및 발언자별 의견 항목 제거 |
| 1.4.18 | 2025-10-24 | 강지수 | 05-회의진행 실시간 주요 메모 추천 기능 명확화 (유저스토리 v2.1.1)<br>- **AI 제안 탭 기능 상세화**: 실시간 주요 메모 추천 기능 명시 추가<br> - UFR-MEET-030: 실시간 AI 주요 메모 추천<br> - 음성→텍스트 변환 후 AI가 실시간 분석<br> - **중요한 내용으로 판단된 경우에만** 주요 메모 항목 추천<br> - 추천 빈도는 중요 내용 발생에 따라 가변적 (3-5초 고정 간격 아님)<br> - 각 추천 항목에 "주요 메모에 추가" 버튼 제공<br> - 실시간 업데이트: 새로운 추천은 상단에 표시<br>- **프로토타입 확인**: 05-회의진행.html의 AI 제안 탭이 실시간 주요 메모 추천 기능을 포함하고 있음을 확인<br>- **참조**: design/uiux/요구사항설계검토-report-V1.2.md (실시간 주요 메모 추천 명시 부족 개선) |
| 1.4.19 | 2025-10-24 | 강지수 | 05-회의진행 화면 설계서 프로토타입 기준 전면 수정<br>- **레이아웃 구조 변경**: "2열 구조" 표현 제거, "메인 콘텐츠 영역: 정보 패널 (탭 구조)"로 단순화<br> - 텍스트 편집 영역 관련 내용 모두 제거 (왼쪽 영역, 에디터 툴바, contentEditable 등)<br> - 현재 프로토타입은 헤더 + 탭 콘텐츠 구조만 보유<br>- **반응형 디자인 명확화**: Mobile/Desktop 모두 동일한 구조에 너비만 반응형<br> - "2열 구조를 1열로 전환", "바텀시트" 표현 제거<br> - Mobile: 전체 너비 사용, Desktop: 최대 너비 제한 없이 반응형<br>- **AI 제안 탭 기능 명확화**: 논의항목/결정사항 구분 제거<br> - "논의항목/결정사항 등의 구분 없이 중요 내용을 주요 메모로 제안" 명시<br> - AI는 단순히 중요한 내용을 주요 메모 항목으로 제안하는 역할만 수행<br>- **용어 사전 검색 기능 추가**: 검색 입력창 + 검색 버튼<br> - Enter 키 지원, 용어명과 정의 모두 검색<br> - 검색 동작 상세 설명: 일치하는 용어만 표시, 하이라이트 효과, 결과 없으면 전체 목록 표시<br>- **인터랙션 섹션 정리**: 텍스트 편집, 툴바 사용, 충돌 감지 등 편집 관련 내용 모두 제거<br> - 탭 전환, 회의 종료, 실시간 업데이트만 유지<br> - 실시간 업데이트 항목을 현재 화면에 맞게 수정 (AI 제안, 용어 사전, 관련 회의록)<br>- **데이터 요구사항 업데이트**: 사용자 편집 내용 제거, 참석자 초대 이메일 추가<br> - AI 제안을 "주요 메모 항목 제안"으로 명확히 표현<br>- **에러 처리 업데이트**: 편집 충돌 에러 제거, 용어 사전 로드 실패/참석자 초대 실패 추가<br>- **주요 기능 목록 정리**: 실시간 협업/수동 편집 제거, AI 주요 메모 제안/참석자 관리 추가<br>- **권한 항목 수정**: "회의록 편집: 모든 참석자" → "참석자 초대: 모든 참석자"<br>- **프로토타입 기준 반영**: 05-회의진행.html 실제 구현 상태 100% 반영 |
--- ---

View File

@ -0,0 +1,766 @@
# 유저스토리 v2.1.2 vs UI/UX 설계 크로스 체크 리포트
**작성일**: 2025-10-24
**작성자**: AI Assistant (Claude)
**버전**: 1.0
---
## 1. 주요 발견사항 요약
### 전체 요약
- **분석 대상**: 유저스토리 v2.1.2, UI/UX 설계서 v1.4.14, 프로토타입 파일 13개
- **불일치 항목 수**: 총 12개 (🔴 높음 4개, 🟡 중간 5개, 🟢 낮음 3개)
- **주요 이슈**: 07-회의종료 화면 기능 불일치, 06-검증완료 화면 존재 여부, 용어 사용 불일치
### 중요도별 분류
#### 🔴 높음 (즉시 수정 필요)
1. **07-회의종료 화면 편집 불가 정책 미반영**
2. **07-회의종료 화면 3가지 선택 옵션 미반영**
3. **07-회의종료 화면 안건별 AI 요약 표시 미반영**
4. **06-검증완료 화면 삭제 필요**
#### 🟡 중간 (우선 수정 권장)
5. **11-회의록수정 화면 안건 기반 구조 미반영**
6. **11-회의록수정 화면 안건별 AI 한줄 요약 미표시**
7. **11-회의록수정 화면 안건별 검증 UI 미구현**
8. **용어 통일 필요: "작성자" → "생성자"/"참석자"**
9. **회의록목록조회 화면 "생성자" 표시 미반영**
#### 🟢 낮음 (검토 후 수정)
10. **05-회의진행 화면 실시간 주요 메모 추천 기능 명시 부족**
11. **10-회의록상세조회 화면 안건별 표시 명시 부족**
12. **스타일 가이드 안건 관련 컴포넌트 누락**
---
## 2. 화면별 상세 분석
### 07-회의종료
#### 유저스토리 v2.1.2 요구사항
- **UFR-MEET-040 (회의종료)**
- 회의 종료 화면은 **확인 전용 (편집 불가)**
- **안건별 AI 요약 전체 표시**:
- 안건별 AI 한줄 요약 (편집 불가)
- 안건별 상세 요약 (확인만 가능)
- Todo 자동 추출 결과 (확인만 가능)
- 사용자에게 **3가지 선택 옵션** 제공:
- 옵션 1: 회의록 수정 화면으로 이동
- 옵션 2: 바로 최종 확정 (모든 안건 자동 검증 완료)
- 옵션 3: 대시보드로 이동
- 회의록 상태: 옵션 1, 3 선택 시 "작성중", 옵션 2 선택 시 "확정완료"
#### UI/UX 설계서 내용
- **화면 목적**: 회의 통계 표시 및 최종 회의록 확정
- 주요 기능:
1. 회의 통계 표시
2. 주요 키워드 클라우드
3. AI 자동 추출된 Todo 항목 확인
4. **최종 회의록 확정** ← 편집 불가 정책 미반영
5. 다음 액션 선택 (공유, 수정, 대시보드 복귀) ← 3가지 옵션 불일치
- **AI Todo 추출 결과**:
- "AI가 추출한 Todo" 섹션
- Todo 항목 리스트 (담당자, 마감일)
- **"Todo 수정" 버튼** ← 편집 불가 정책 위반
- **하단 액션**:
- "회의록 공유하기" 버튼 ← v2.1.2에 없음
- "회의록 수정하기" 버튼
- "대시보드로 돌아가기" 버튼
#### 프로토타입 구현 상태 (07-회의종료.html)
```html
<!-- AI Todo 추출 결과 -->
<button class="btn btn-ghost btn-sm" onclick="openModal('todoEditModal')">수정</button>
<!-- ← 편집 불가 정책 위반 -->
<!-- 하단 액션 바 -->
<button class="btn btn-secondary" onclick="navigateTo('11-회의록수정.html')">수정</button>
<button class="btn btn-primary" onclick="navigateTo('02-대시보드.html')">대시보드로 이동</button>
<!-- ← 3가지 옵션 미구현, "바로 최종 확정" 옵션 없음 -->
```
#### 불일치 사항
1. 🔴 **편집 불가 정책 미반영**
- 유저스토리: 확인 전용, 편집 불가
- UI/UX 설계서 & 프로토타입: "Todo 수정" 버튼 존재
- **수정 필요**: "수정" 버튼 제거, 확인만 가능하도록 변경
2. 🔴 **3가지 선택 옵션 미반영**
- 유저스토리: 회의록 수정 / 바로 최종 확정 / 대시보드 이동
- 프로토타입: 수정 / 대시보드 이동만 있음
- **수정 필요**: "바로 최종 확정" 버튼 추가, 선택 시 모든 안건 자동 검증 처리
3. 🔴 **안건별 AI 요약 표시 미반영**
- 유저스토리: 안건별 AI 한줄 요약 + 상세 요약 전체 표시
- UI/UX 설계서 & 프로토타입: Todo만 표시, 안건 구조 없음
- **수정 필요**: 안건별 섹션으로 구조화, 각 안건의 AI 요약 표시
4. 🟢 **"회의록 공유하기" 버튼 존재**
- UI/UX 설계서: "회의록 공유하기" 버튼 있음
- 유저스토리 v2.1.2: 공유 기능 제거됨 (v2.0.1에서)
- **수정 필요**: "공유" 버튼 제거
#### 권장 수정사항
```
[하단 액션 바 수정안]
- "회의록 수정" 버튼 (옵션 1)
- "바로 최종 확정" 버튼 (옵션 2, Primary)
- "대시보드로 이동" 버튼 (옵션 3)
[AI Todo 추출 결과 섹션 수정안]
- "Todo 수정" 버튼 제거
- 확인만 가능하도록 readonly 처리
[안건별 AI 요약 표시 추가]
- 각 안건별 카드로 표시
- 안건 제목
- AI 한줄 요약 (읽기 전용)
- 상세 요약 (읽기 전용)
- Todo 목록 (읽기 전용)
```
---
### 11-회의록수정
#### 유저스토리 v2.1.2 요구사항
- **UFR-MEET-055 (회의록수정)**
- 진입 경로: 10-회의록상세조회 → "수정" 버튼 클릭
- **안건 기반 회의록 구조**:
- 각 안건별 섹션
- 안건별 AI 한줄 요약 (편집 불가)
- 안건별 상세 요약 (편집 가능)
- 안건별 검증 상태 (체크박스)
- 수정 가능 항목:
- ✅ 회의 제목
- ❌ 회의 일시/장소 (readonly)
- ✅ 참석자 목록 (회의 생성자만)
- ✅ 안건별 AI 요약 (AI 재생성)
- ✅ 안건별 내용
- ✅ 관련회의록
- 검증완료 안건: 회의 생성자만 잠금 해제 후 수정 가능
- **UFR-AI-036 (AI한줄요약)**
- 각 안건마다 편집 불가능한 AI 한줄 요약 제공
- 30자 이내 간결한 표현
- 회의 종료 시 1회 생성, 생성 후 편집 불가
- **UFR-COLLAB-030 (검증완료)**
- 안건별 검증 완료 처리
- 11-회의록수정 화면에서 안건별 검증 처리
- 별도의 06-검증완료 화면 불필요
#### UI/UX 설계서 내용
- **주요 기능**:
1. 회의 기본 정보 표시 및 수정
2. 회의록 내용 수정 **(섹션별)** ← 안건별이 아닌 섹션별
3. AI 요약 수정 (섹션별)
4. 참고자료 편집
5. Todo 수정 (회의 생성자만)
6. 자동 저장
- **섹션 구조**:
- 섹션 1 편집: "1. 신제품 기획 방향"
- 섹션 2 편집: "2. 개발 일정 및 리소스"
- 섹션 3 편집: "3. 마케팅 전략"
- ← "섹션" 용어 사용, "안건" 용어 없음
- **AI 요약 편집**:
- AI 요약 텍스트 필드 (편집 가능)
- "AI 재생성" 버튼
- ← AI 한줄 요약 (편집 불가) 항목 없음
- **검증 완료 표시**:
- 체크박스 (검증 완료, disabled)
- 🔒 읽기 전용 배지
- ← 섹션별 검증, 안건별 검증 아님
#### 프로토타입 구현 상태 (11-회의록수정.html)
```html
<!-- 섹션 1 편집 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">
1. 신제품 기획 방향
<span class="badge badge-complete">검증완료</span>
</h3>
</div>
<!-- AI 요약 편집 -->
<div class="ai-summary-edit">
<div class="ai-summary-header">
<span class="ai-summary-label">💡 AI 요약</span>
<button class="btn-secondary btn-sm" onclick="regenerateSummary(1)">AI 재생성</button>
</div>
<textarea class="ai-summary-textarea" readonly>
신제품은 AI 기반 회의록 자동화 서비스로 결정...
</textarea>
<!-- ← AI 한줄 요약 (편집 불가) 항목 없음 -->
</div>
<!-- 검증 완료 (읽기 전용) -->
<div class="verification-lock">
<input type="checkbox" class="checkbox" id="verify-1" checked disabled>
<label for="verify-1">
<span class="font-medium">검증 완료</span>
<span class="text-caption text-muted"> (잠금됨 · 회의 생성자만 수정 가능)</span>
</label>
<span class="readonly-badge">🔒 읽기 전용</span>
</div>
</div>
```
#### 불일치 사항
1. 🟡 **안건 기반 구조 미반영**
- 유저스토리: "안건별" 회의록 구조
- UI/UX 설계서 & 프로토타입: "섹션별" 구조
- **수정 필요**: "섹션" 용어를 "안건"으로 통일
2. 🟡 **안건별 AI 한줄 요약 미표시**
- 유저스토리: 각 안건마다 편집 불가능한 AI 한줄 요약 (30자 이내)
- 프로토타입: AI 요약은 있지만 "한줄 요약"과 "상세 요약" 구분 없음
- **수정 필요**:
- AI 한줄 요약 (읽기 전용, 30자) 추가
- 기존 AI 요약을 "상세 요약"으로 명칭 변경
3. 🟡 **안건별 검증 UI 구현 상태**
- 유저스토리: 11-회의록수정 화면에서 안건별 검증 처리
- 프로토타입: 검증완료 체크박스 있으나 disabled (수정 불가)
- **검토 필요**:
- 회의 생성자일 때 체크박스 활성화 필요
- 잠금 해제 버튼 추가 고려
#### 권장 수정사항
```
[안건 구조 수정안]
<!-- 안건 1 편집 -->
<div class="agenda-section">
<div class="agenda-header">
<h3 class="agenda-title">
안건 1. 신제품 기획 방향
<span class="badge badge-complete">검증완료</span>
</h3>
</div>
<!-- AI 한줄 요약 (편집 불가) -->
<div class="ai-oneline-summary-readonly">
<span class="ai-icon"></span>
<span class="summary-text">AI 기반 회의록 서비스 개발 방향 결정</span>
</div>
<!-- AI 상세 요약 (편집 가능) -->
<div class="ai-summary-edit">
<div class="ai-summary-header">
<span class="ai-summary-label">💡 AI 상세 요약</span>
<button class="btn-secondary btn-sm">AI 재생성</button>
</div>
<textarea class="ai-summary-textarea">...</textarea>
</div>
<!-- 안건별 검증 (회의 생성자만 활성화) -->
<div class="agenda-verification">
<input type="checkbox" id="verify-agenda-1" checked>
<label>안건 검증 완료</label>
<button class="btn-ghost btn-sm" v-if="isCreator && isLocked">잠금 해제</button>
</div>
</div>
```
---
### 06-검증완료
#### 유저스토리 v2.1.2 요구사항
- **UFR-COLLAB-030 (검증완료)**:
- 11-회의록수정 화면에서 안건별 검증 처리
- **별도의 06-검증완료 화면 불필요**
#### UI/UX 설계서 내용
- **화면 존재**: 06-검증완료 화면 정의됨
- 주요 기능:
1. 섹션별 검증 상태 표시
2. 검증 완료 체크 (참석자별)
3. 미검증 섹션 안내
4. 섹션 잠금 (회의 생성자만)
#### 프로토타입 구현 상태
- **파일 존재**: `06-검증완료.html` (528줄)
- 주요 기능 구현:
- 섹션별 검증 카드
- 검증 완료 버튼
- 잠금 해제 버튼
- 진행률 표시
#### 불일치 사항
1. 🔴 **06-검증완료 화면 삭제 필요**
- 유저스토리: 별도 화면 불필요, 11-회의록수정에 통합
- UI/UX 설계서 & 프로토타입: 06-검증완료 화면 존재
- **수정 필요**:
- 06-검증완료.html 파일 삭제
- UI/UX 설계서에서 해당 화면 설명 제거
- 모든 링크 및 내비게이션에서 제거
#### 권장 수정사항
```
1. 프로토타입 파일 삭제
- design/uiux/prototype/06-검증완료.html 삭제
2. UI/UX 설계서 수정
- "### 06-검증완료" 섹션 전체 삭제
- 프로토타입 화면 목록 테이블에서 제거
3. 11-회의록수정 화면에 검증 기능 통합
- 각 안건별 검증 체크박스 추가
- 회의 생성자는 검증 상태 변경 가능
- 참석자는 자신의 검증만 처리 가능
```
---
### 10-회의록상세조회
#### 유저스토리 v2.1.2 요구사항
- **UFR-MEET-047 (회의록상세조회)**
- 회의 기본 정보 표시 (제목, 일시, 참석자, 장소, 상태)
- **안건별 상세 내용 표시** (섹션별 → 안건별)
- AI 요약 섹션 (안건별)
- 상세 내용 섹션 (논의 사항, 결정 사항 등)
- 관련 회의록 섹션
#### UI/UX 설계서 내용
- 탭 구성: 대시보드 / 회의록 (기본: 대시보드)
- **대시보드 탭**:
- 핵심내용 카드 (AI 요약)
- 결정사항 카드
- Todo 진행상황 카드
- 참고자료 카드
- **회의록 탭**:
- 회의 기본 정보
- 섹션별 AI 요약 및 내용 ← 안건별이 아님
#### 프로토타입 구현 상태 (10-회의록상세조회.html)
- 탭: 대시보드 / 회의록
- 섹션 구조 (회의록 탭):
```html
<div class="section">
<h3 class="section-title">1. 신제품 기획 방향</h3>
<div class="ai-summary">...</div>
<div class="section-content">...</div>
</div>
```
#### 불일치 사항
1. 🟢 **안건별 표시 명시 부족**
- 유저스토리: "안건별 상세 내용 표시"
- UI/UX 설계서 & 프로토타입: "섹션별" 용어 사용
- **수정 필요**: "섹션"을 "안건"으로 명칭 변경
#### 권장 수정사항
```
[UI/UX 설계서 수정]
- "섹션별 상세 내용 표시" → "안건별 상세 내용 표시"
- "섹션별 AI 요약" → "안건별 AI 요약"
[프로토타입 수정]
- class="section" → class="agenda"
- class="section-title" → class="agenda-title"
- HTML 주석 및 변수명 일괄 변경
```
---
### 05-회의진행
#### 유저스토리 v2.1.2 요구사항
- **UFR-AI-010 (회의록자동작성) - 시나리오 1**:
- **실시간 AI 주요 메모 작성**
- 텍스트 변환되면 자동으로 주요 메모 항목 추천
- 실시간 업데이트 (3-5초 간격)
- 참석자가 필요한 항목만 선택하여 저장
#### UI/UX 설계서 내용
- 주요 기능:
1. 음성 녹음 및 STT
2. 회의록 실시간 편집
3. 참석자 목록 관리
4. AI 제안 기능 (우측 탭)
- **데이터 출력**:
- 실시간 텍스트 변환 결과 (STT)
- 편집된 회의록 내용
- AI 제안 목록 (회의록 개선 제안) ← 주요 메모 추천과 차이
#### 프로토타입 구현 상태 (05-회의진행.html)
- 우측 탭:
- 참석자
- AI 제안 (논의사항 제안, 결정사항 제안, 액션아이템 제안)
- 용어 사전
- 관련 자료
- ← 실시간 주요 메모 추천 기능 명시 없음
#### 불일치 사항
1. 🟢 **실시간 주요 메모 추천 기능 명시 부족**
- 유저스토리: 실시간 AI 주요 메모 항목 추천 (3-5초 간격)
- UI/UX 설계서: "AI 제안 목록 (회의록 개선 제안)"
- 프로토타입: 논의사항/결정사항/액션아이템 제안만 있음
- **검토 필요**:
- 실시간 주요 메모 추천 기능이 "AI 제안"에 포함된 것인지 명확화
- 별도 UI 필요 여부 검토
#### 권장 수정사항
```
[UI/UX 설계서 명확화]
- "AI 제안 기능" 섹션에 다음 추가:
"실시간 AI 주요 메모 추천:
- 텍스트 변환 후 3-5초 간격으로 주요 메모 항목 자동 추천
- 참석자가 선택하여 저장
- 우측 'AI 제안' 탭에서 확인 가능"
[프로토타입 검토]
- 현재 AI 제안 탭 기능이 실시간 주요 메모 추천인지 확인
- 필요 시 별도 UI 컴포넌트 추가
```
---
### 12-회의록목록조회
#### 유저스토리 v2.1.2 요구사항
- **UFR-MEET-046 (회의록목록조회)**
- 필터: 참여 유형(참석한/생성한), 상태(전체/작성중/확정완료)
- 목록 표시 정보:
- 회의 제목
- 회의 일시
- 참석자 수
- 회의록 상태
- 검증 완료율
- **생성자 표시 (👑 아이콘)** ← v2.1.2에서 추가됨
- 마지막 수정 시간
#### UI/UX 설계서 내용
- 필터 및 정렬:
- 참여 유형: 참석한 회의 / 생성한 회의
- 상태: 전체 / 작성중 / 확정완료
- 목록 카드 정보:
- 회의 제목
- 날짜/시간
- 참석자 수
- 상태 배지
- 검증률 (작성중인 경우)
- ← 생성자 표시 명시 없음
#### 프로토타입 구현 상태 (12-회의록목록조회.html)
- 필터 및 정렬 구현됨
- 회의록 카드:
```javascript
// common.js - renderMinuteCard 함수
<div class="minute-card">
<div class="minute-title">${minute.title}</div>
<!-- 생성자 표시 로직 없음 -->
</div>
```
#### 불일치 사항
1. 🟡 **생성자 표시 미반영**
- 유저스토리: 생성자 표시 (👑 아이콘)
- UI/UX 설계서: 명시 없음
- 프로토타입: 구현 없음
- **수정 필요**:
- 생성자 표시 UI 추가
- 현재 사용자가 생성자일 경우 👑 아이콘 표시
#### 권장 수정사항
```
[UI/UX 설계서 수정]
"목록 표시 정보" 섹션에 추가:
- 생성자 표시: 현재 사용자가 회의 생성자인 경우 👑 아이콘 표시
[프로토타입 수정 - common.js]
function renderMinuteCard(minute, currentUserId) {
const isCreator = minute.creatorId === currentUserId;
return `
<div class="minute-card">
<div class="minute-header">
<h3 class="minute-title">
${minute.title}
${isCreator ? '<span class="creator-badge">👑</span>' : ''}
</h3>
</div>
...
</div>
`;
}
```
---
## 3. 용어 사용 불일치
### "작성자" vs "생성자"/"참석자"
#### 유저스토리 v2.1.2 용어 정책
- **v2.1.2 주요 변경사항**: 역할 용어 통일
- "작성자" → "회의 생성자" 또는 "참석자"
- 회의를 만든 사람: **회의 생성자** (creator)
- 회의에 참여한 사람: **참석자** (attendee)
#### UI/UX 설계서 용어 사용 현황
- **일관성 있는 곳**: 대부분 "회의 생성자", "참석자" 사용
- **"작성자" 사용 위치**:
1. UFR-TODO-020 (Todo완료): "Todo 작성자에게 완료 알림 발송"
2. UFR-TODO-040 (Todo관리): "담당자 본인 OR 회의 작성자인 경우에만 노출"
3. 일부 화면 설명에서 혼용
#### 프로토타입 용어 사용 현황
- 대부분 "회의 생성자" 사용
- 일부 주석에서 "작성자" 사용
#### 불일치 사항
1. 🟡 **용어 통일 필요**
- 유저스토리: "회의 생성자" 일관 사용
- UI/UX 설계서: 일부 "작성자" 혼재
- **수정 필요**: 모든 "작성자"를 "회의 생성자"로 변경
#### 권장 수정사항
```
[UI/UX 설계서 일괄 변경]
1. 검색 및 치환:
- "Todo 작성자" → "Todo 담당자" 또는 "회의 생성자"
- "회의 작성자" → "회의 생성자"
- "회의록 작성자" → "회의 생성자"
2. 컨텍스트별 명확화:
- 회의를 만든 사람: "회의 생성자"
- Todo를 만든 사람: "Todo 담당자"
- 회의에 참여한 사람: "참석자"
[프로토타입 수정]
- 주석 및 변수명에서 "작성자" → "생성자" 변경
- 예: creator, isCreator 등으로 통일
```
---
## 4. 권장 수정사항 우선순위
### Phase 1: 즉시 수정 (🔴 높음)
#### 1.1 07-회의종료 화면 전면 개편
**담당**: 강지수 (Product Designer)
**공수**: 2일
**작업 내역**:
1. 프로토타입 수정 (07-회의종료.html):
- 안건별 AI 요약 표시 추가
- "Todo 수정" 버튼 제거
- 하단 액션 3가지 옵션 구현:
- "회의록 수정" (옵션 1)
- "바로 최종 확정" (옵션 2, Primary)
- "대시보드로 이동" (옵션 3)
- "바로 최종 확정" 버튼 클릭 시 모든 안건 자동 검증 처리 로직 추가
2. UI/UX 설계서 수정 (uiux.md):
- "07-회의종료" 섹션 전체 재작성
- 편집 불가 정책 명시
- 안건별 AI 요약 표시 설명 추가
- 3가지 선택 옵션 상세 설명
- 각 옵션별 회의록 상태 변경 로직 명시
#### 1.2 06-검증완료 화면 삭제
**담당**: 강지수 (Product Designer)
**공수**: 0.5일
**작업 내역**:
1. 파일 삭제:
- design/uiux/prototype/06-검증완료.html 삭제
2. UI/UX 설계서 수정:
- "### 06-검증완료" 섹션 전체 삭제
- 프로토타입 화면 목록 테이블에서 제거
- 모든 화면에서 06-검증완료 링크 제거
3. 11-회의록수정 화면에 검증 기능 통합:
- 각 안건별 검증 체크박스 UI 추가
- 권한별 활성화 로직 구현
---
### Phase 2: 우선 수정 (🟡 중간)
#### 2.1 11-회의록수정 화면 안건 기반 구조 전환
**담당**: 강지수 (Product Designer)
**공수**: 3일
**작업 내역**:
1. 프로토타입 수정 (11-회의록수정.html):
- "섹션" → "안건" 용어 변경
- AI 한줄 요약 (읽기 전용) UI 추가
- AI 상세 요약 (편집 가능) 명칭 변경
- 안건별 검증 체크박스 추가
- 잠금 해제 버튼 추가 (회의 생성자만)
2. UI/UX 설계서 수정:
- "### 11-회의록수정" 섹션 재작성
- 안건 기반 구조 설명 추가
- AI 한줄 요약 vs 상세 요약 구분 설명
- 안건별 검증 UI 설명 추가
3. 스타일 가이드 업데이트 (style-guide.md):
- 안건 카드 컴포넌트 추가
- AI 한줄 요약 스타일 정의
- 안건별 검증 UI 스타일 정의
#### 2.2 용어 통일 ("작성자" → "생성자")
**담당**: 강지수 (Product Designer)
**공수**: 1일
**작업 내역**:
1. UI/UX 설계서 일괄 변경:
- "작성자" 검색 및 컨텍스트 확인
- "회의 생성자" 또는 "Todo 담당자"로 변경
2. 프로토타입 수정:
- 주석 및 변수명 일괄 변경
- creator, isCreator 등으로 통일
3. 용어 사전 추가 (uiux.md):
- "회의 생성자": 회의를 생성한 사람
- "참석자": 회의에 참여한 사람
- "Todo 담당자": Todo를 담당하는 사람
#### 2.3 회의록목록조회 생성자 표시 추가
**담당**: 강지수 (Product Designer)
**공수**: 0.5일
**작업 내역**:
1. 프로토타입 수정 (common.js):
- renderMinuteCard 함수에 생성자 표시 로직 추가
- 👑 아이콘 추가
2. UI/UX 설계서 수정:
- 목록 표시 정보에 "생성자 표시" 항목 추가
---
### Phase 3: 검토 후 수정 (🟢 낮음)
#### 3.1 05-회의진행 화면 실시간 주요 메모 추천 명확화
**담당**: 강지수, 도그냥
**공수**: 1일
**작업 내역**:
1. 현재 구현 검토:
- AI 제안 탭 기능이 실시간 주요 메모 추천인지 확인
- 유저스토리와 일치하는지 검증
2. UI/UX 설계서 명확화:
- 실시간 주요 메모 추천 기능 설명 추가
- AI 제안 탭과의 관계 명시
3. 필요 시 프로토타입 수정:
- 별도 UI 컴포넌트 추가
- 3-5초 간격 업데이트 로직 구현
#### 3.2 10-회의록상세조회 안건별 표시 명칭 변경
**담당**: 강지수
**공수**: 0.5일
**작업 내역**:
1. UI/UX 설계서 수정:
- "섹션별" → "안건별" 용어 변경
2. 프로토타입 수정:
- class 명칭 변경
- 주석 및 변수명 변경
#### 3.3 스타일 가이드 안건 컴포넌트 추가
**담당**: 강지수
**공수**: 1일
**작업 내역**:
1. style-guide.md 수정:
- 안건 카드 컴포넌트 스타일 정의
- AI 한줄 요약 스타일 추가
- 안건별 검증 UI 스타일 추가
- 예시 코드 작성
---
## 5. 총 작업 공수 및 일정
### 공수 요약
- **Phase 1 (즉시 수정)**: 2.5일
- 07-회의종료 화면 전면 개편: 2일
- 06-검증완료 화면 삭제: 0.5일
- **Phase 2 (우선 수정)**: 4.5일
- 11-회의록수정 안건 기반 구조 전환: 3일
- 용어 통일: 1일
- 생성자 표시 추가: 0.5일
- **Phase 3 (검토 후 수정)**: 2.5일
- 실시간 주요 메모 추천 명확화: 1일
- 안건별 표시 명칭 변경: 0.5일
- 스타일 가이드 업데이트: 1일
**총 공수**: 9.5일
### 권장 일정
- **Week 1 (5일)**: Phase 1 완료 + Phase 2 시작
- Day 1-2: 07-회의종료 전면 개편
- Day 3: 06-검증완료 삭제 + 용어 통일
- Day 4-5: 11-회의록수정 안건 구조 전환 (50%)
- **Week 2 (4.5일)**: Phase 2 완료 + Phase 3
- Day 6-7: 11-회의록수정 안건 구조 전환 완료
- Day 8: 생성자 표시 추가 + Phase 3 검토
- Day 9-10: Phase 3 수정 작업
---
## 6. 체크리스트
### Phase 1 완료 체크리스트
- [ ] 07-회의종료.html 안건별 AI 요약 표시 구현
- [ ] 07-회의종료.html "Todo 수정" 버튼 제거
- [ ] 07-회의종료.html 3가지 선택 옵션 구현
- [ ] 07-회의종료.html "바로 최종 확정" 로직 구현
- [ ] uiux.md "07-회의종료" 섹션 재작성
- [ ] 06-검증완료.html 파일 삭제
- [ ] uiux.md "06-검증완료" 섹션 삭제
- [ ] 프로토타입 화면 목록 테이블 업데이트
### Phase 2 완료 체크리스트
- [ ] 11-회의록수정.html "섹션" → "안건" 용어 변경
- [ ] 11-회의록수정.html AI 한줄 요약 UI 추가
- [ ] 11-회의록수정.html 안건별 검증 체크박스 추가
- [ ] uiux.md "11-회의록수정" 섹션 재작성
- [ ] uiux.md 전체 "작성자" → "생성자" 변경
- [ ] 프로토타입 용어 통일 (creator, isCreator)
- [ ] common.js 생성자 표시 로직 추가
- [ ] uiux.md "12-회의록목록조회" 생성자 표시 설명 추가
### Phase 3 완료 체크리스트
- [ ] 05-회의진행 실시간 주요 메모 추천 검토 완료
- [ ] uiux.md 실시간 주요 메모 추천 설명 추가
- [ ] 10-회의록상세조회 "섹션" → "안건" 변경
- [ ] style-guide.md 안건 컴포넌트 스타일 추가
---
## 7. 결론
### 주요 발견사항
1. 유저스토리 v2.1.2의 핵심 변경사항인 **"안건 기반 회의록 구조"**가 UI/UX 설계서와 프로토타입에 충분히 반영되지 않았습니다.
2. **07-회의종료 화면**의 "확인 전용" 정책과 "3가지 선택 옵션"이 구현되지 않아, 사용자 경험에 큰 영향을 미칠 수 있습니다.
3. **06-검증완료 화면**이 여전히 존재하여, 유저스토리의 "11-회의록수정 통합" 방침과 불일치합니다.
4. **용어 사용**이 일부 혼재되어 있어, 전체적인 일관성 개선이 필요합니다.
### 권장사항
1. **Phase 1 (즉시 수정)** 항목을 최우선으로 처리하여 핵심 사용자 플로우를 유저스토리와 일치시켜야 합니다.
2. **Phase 2 (우선 수정)** 항목은 전체적인 일관성과 정확성을 위해 2주 내 완료를 권장합니다.
3. **Phase 3 (검토 후 수정)** 항목은 검토 과정에서 실제 불일치 여부를 확인한 후 수정 여부를 결정하시기 바랍니다.
4. 모든 수정 작업 후 **통합 테스트**를 통해 유저스토리, UI/UX 설계서, 프로토타입 간 완전한 일관성을 확보해야 합니다.
---
**보고서 종료**

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,9 @@ task printEnv {
} }
dependencies { dependencies {
// Module dependencies
implementation project(':notification')
// WebSocket // WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty' implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
nohup: ./gradlew: No such file or directory

1
meeting/logs/meeting.log Normal file
View File

@ -0,0 +1 @@
nohup: ./gradlew: No such file or directory

View File

@ -38,16 +38,21 @@ public class Meeting {
*/ */
private String purpose; private String purpose;
/**
* 회의 장소
*/
private String location;
/** /**
* 회의 일시 * 회의 일시
*/ */
private LocalDateTime scheduledAt; private LocalDateTime scheduledAt;
/**
* 회의 종료 예정 일시
*/
private LocalDateTime endTime;
/**
* 회의 장소
*/
private String location;
/** /**
* 회의 시작 일시 * 회의 시작 일시
*/ */
@ -114,7 +119,20 @@ public class Meeting {
public void addParticipant(String participantEmail) { public void addParticipant(String participantEmail) {
if (this.participants == null) { if (this.participants == null) {
this.participants = new ArrayList<>(); this.participants = new ArrayList<>();
} else if (!(this.participants instanceof ArrayList)) {
// 불변 리스트인 경우 새로운 ArrayList로 변환
this.participants = new ArrayList<>(this.participants);
} }
if (!this.participants.contains(participantEmail)) {
this.participants.add(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

@ -1,11 +1,15 @@
package com.unicorn.hgzero.meeting.biz.domain; package com.unicorn.hgzero.meeting.biz.domain;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List; import java.util.List;
/** /**
@ -77,6 +81,101 @@ public class Minutes {
*/ */
private LocalDateTime finalizedAt; private LocalDateTime finalizedAt;
/**
* 회의록 확정 가능 여부 검증
*
* @param meeting 회의 정보
* @param userId 확정 요청자 ID
* @throws BusinessException 검증 실패
*/
public void validateCanConfirm(Meeting meeting, String userId) {
List<String> errors = new ArrayList<>();
// 1. 상태 검증
if (!"DRAFT".equals(this.status)) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "회의록이 작성중 상태가 아닙니다. 현재 상태: " + this.status);
}
if (!"COMPLETED".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, "회의가 종료되지 않았습니다. 현재 회의 상태: " + meeting.getStatus());
}
// 2. 권한 검증
boolean isOrganizer = meeting.getOrganizerId().equals(userId);
boolean isParticipant = meeting.getParticipants() != null && meeting.getParticipants().contains(userId);
if (!isOrganizer && !isParticipant) {
throw new BusinessException(ErrorCode.ACCESS_DENIED, "회의록 확정 권한이 없습니다.");
}
// 3. 필수 항목 검증
if (this.title == null || this.title.trim().isEmpty()) {
errors.add("회의록 제목이 없습니다.");
} else if (this.title.trim().length() < 5) {
errors.add("회의록 제목은 최소 5자 이상이어야 합니다.");
}
if (meeting.getParticipants() == null || meeting.getParticipants().isEmpty()) {
errors.add("참석자가 최소 1명 이상 있어야 합니다.");
}
// 섹션 검증
if (this.sections != null && !this.sections.isEmpty()) {
boolean hasDiscussionContent = false;
boolean hasDecisionContent = false;
for (MinutesSection section : this.sections) {
if ("DISCUSSION".equals(section.getType()) && section.getContent() != null && section.getContent().trim().length() >= 20) {
hasDiscussionContent = true;
}
if ("DECISION".equals(section.getType())) {
if (section.getContent() != null && !section.getContent().trim().isEmpty()) {
hasDecisionContent = true;
}
}
}
if (!hasDiscussionContent) {
errors.add("주요 논의 내용이 없거나 20자 미만입니다.");
}
if (!hasDecisionContent) {
errors.add("결정 사항이 없습니다. (결정사항이 없는 경우 '결정사항 없음'을 명시해주세요)");
}
} else {
errors.add("회의록 섹션이 없습니다.");
}
// 4. 데이터 무결성 검증
if (this.sections != null) {
for (MinutesSection section : this.sections) {
// 필수 필드 검증
if (section.getTitle() == null || section.getTitle().trim().isEmpty()) {
errors.add("섹션 제목이 비어있는 섹션이 있습니다. (섹션 ID: " + section.getSectionId() + ")");
}
if (section.getContent() == null || section.getContent().trim().isEmpty()) {
errors.add("섹션 내용이 비어있는 섹션이 있습니다. (섹션 ID: " + section.getSectionId() + ")");
}
}
}
// 검증 오류가 있으면 예외 발생
if (!errors.isEmpty()) {
String errorMessage = "회의록 확정 검증 실패:\n" + String.join("\n", errors);
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE, errorMessage);
}
// 5. 이력 검증 (경고)
if (this.lastModifiedAt != null) {
Duration duration = Duration.between(this.lastModifiedAt, LocalDateTime.now());
if (duration.toHours() > 24) {
// 로그로 경고만 출력 (진행 가능)
// TODO: 경고 메시지를 응답에 포함시키는 방법 고려
}
}
}
/** /**
* 회의록 확정 * 회의록 확정
*/ */
@ -114,4 +213,17 @@ public class Minutes {
this.title = title; this.title = title;
this.version++; this.version++;
} }
/**
* 모든 섹션 잠금
*/
public void lockAllSections(String userId) {
if (this.sections != null) {
for (MinutesSection section : this.sections) {
if (!section.isLocked()) {
section.lock(userId);
}
}
}
}
} }

View File

@ -0,0 +1,70 @@
package com.unicorn.hgzero.meeting.biz.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 회의 세션 도메인 모델
* 회의가 시작되면 생성되고 회의 종료 종료됨
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Session {
/**
* 세션 ID (UUID)
*/
private String sessionId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 회의록 ID
*/
private String minutesId;
/**
* 세션 시작자 (사용자 ID)
*/
private String startedBy;
/**
* 세션 시작 시간
*/
private LocalDateTime startedAt;
/**
* 세션 종료 시간
*/
private LocalDateTime endedAt;
/**
* 세션 상태 (ACTIVE, CLOSED)
*/
@Builder.Default
private String status = "ACTIVE";
/**
* 세션 종료
*/
public void close() {
this.status = "CLOSED";
this.endedAt = LocalDateTime.now();
}
/**
* 세션이 활성 상태인지 확인
*/
public boolean isActive() {
return "ACTIVE".equals(this.status);
}
}

View File

@ -83,8 +83,8 @@ public class MeetingDTO {
.meetingId(meeting.getMeetingId()) .meetingId(meeting.getMeetingId())
.title(meeting.getTitle()) .title(meeting.getTitle())
.startTime(meeting.getStartedAt() != null ? meeting.getStartedAt() : meeting.getScheduledAt()) .startTime(meeting.getStartedAt() != null ? meeting.getStartedAt() : meeting.getScheduledAt())
.endTime(meeting.getEndedAt())
.purpose(meeting.getPurpose()) .purpose(meeting.getPurpose())
.endTime(meeting.getEndedAt() != null ? meeting.getEndedAt() : meeting.getEndTime())
.location(meeting.getLocation()) .location(meeting.getLocation())
.agenda(meeting.getDescription()) .agenda(meeting.getDescription())
.participants(meeting.getParticipants().stream() .participants(meeting.getParticipants().stream()

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,9 +3,22 @@ package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.common.exception.BusinessException; import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode; import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.meeting.biz.domain.Meeting; 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.in.meeting.*;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader; 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.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;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
import com.unicorn.hgzero.meeting.infra.event.publisher.EventPublisher;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@ -32,6 +45,16 @@ public class MeetingService implements
private final MeetingReader meetingReader; private final MeetingReader meetingReader;
private final MeetingWriter meetingWriter; 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;
private final com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader participantReader;
/** /**
* 회의 생성 * 회의 생성
@ -41,26 +64,81 @@ public class MeetingService implements
public Meeting createMeeting(CreateMeetingCommand command) { public Meeting createMeeting(CreateMeetingCommand command) {
log.info("Creating meeting: {}", command.title()); log.info("Creating meeting: {}", command.title());
// 회의 ID 생성 // 1. 회의 시간 유효성 검사
if (command.scheduledAt().isAfter(command.endTime()) ||
command.scheduledAt().isEqual(command.endTime())) {
log.warn("Invalid meeting time: start={}, end={}",
command.scheduledAt(), command.endTime());
throw new BusinessException(ErrorCode.INVALID_MEETING_TIME);
}
// 2. 중복 회의 체크
long conflictCount = meetingReader.countConflictingMeetings(
command.organizerId(),
command.scheduledAt(),
command.endTime()
);
if (conflictCount > 0) {
log.warn("Meeting time conflict detected: organizerId={}, count={}",
command.organizerId(), conflictCount);
throw new BusinessException(ErrorCode.MEETING_TIME_CONFLICT);
}
// 3. 회의 ID 생성
String meetingId = UUID.randomUUID().toString(); String meetingId = UUID.randomUUID().toString();
// 회의 도메인 객체 생성 // 4. 회의 도메인 객체 생성
Meeting meeting = Meeting.builder() Meeting meeting = Meeting.builder()
.meetingId(meetingId) .meetingId(meetingId)
.title(command.title()) .title(command.title())
.purpose(command.purpose()) .purpose(command.purpose())
.description(command.description()) .description(command.agenda())
.location(command.location()) .location(command.location())
.scheduledAt(command.scheduledAt()) .scheduledAt(command.scheduledAt())
.endTime(command.endTime())
.status("SCHEDULED") .status("SCHEDULED")
.organizerId(command.organizerId()) .organizerId(command.organizerId())
.participants(command.participants()) .participants(command.participants())
.templateId(command.templateId()) .templateId(command.templateId())
.build(); .build();
// 회의 저장 // 5. 회의 저장
Meeting savedMeeting = meetingWriter.save(meeting); Meeting savedMeeting = meetingWriter.save(meeting);
// 5-1. 참석자 목록 저장
if (command.participants() != null && !command.participants().isEmpty()) {
participantWriter.saveParticipants(meetingId, command.participants());
log.debug("Participants saved: meetingId={}, count={}", meetingId, command.participants().size());
}
// 6. 캐시 저장 (TTL: 10분)
try {
cacheService.cacheMeeting(meetingId, savedMeeting, 600);
log.debug("Meeting cached: meetingId={}", meetingId);
} catch (Exception e) {
log.warn("Failed to cache meeting: meetingId={}", meetingId, e);
// 캐시 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
// 7. 참석자 초대 이벤트 발행 (비동기)
try {
eventPublisher.publishMeetingCreated(
meetingId,
command.title(),
command.scheduledAt(),
command.location(),
command.participants(),
command.organizerId(),
command.organizerId() // organizerName은 나중에 User 서비스 연동 개선
);
log.debug("Meeting invitation events published: meetingId={}, participants={}",
meetingId, command.participants().size());
} catch (Exception e) {
log.error("Failed to publish meeting invitation events: meetingId={}", meetingId, e);
// 이벤트 발행 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
log.info("Meeting created successfully: {}", savedMeeting.getMeetingId()); log.info("Meeting created successfully: {}", savedMeeting.getMeetingId());
return savedMeeting; return savedMeeting;
} }
@ -70,53 +148,288 @@ public class MeetingService implements
*/ */
@Override @Override
@Transactional @Transactional
public Meeting startMeeting(String meetingId) { public Session startMeeting(String meetingId) {
log.info("Starting meeting: {}", meetingId); log.info("Starting meeting: {}", meetingId);
// 회의 조회 // 1. Redis 캐시 조회
Meeting meeting = meetingReader.findById(meetingId) Meeting meeting = cacheService.getCachedMeeting(meetingId, Meeting.class);
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
// 회의 상태 검증 // 2. 캐시 미스시 DB 조회 캐싱
if (!"SCHEDULED".equals(meeting.getStatus())) { if (meeting == null) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); log.debug("Cache miss for meeting: {}", meetingId);
meeting = meetingReader.findById(meetingId)
.orElseThrow(() -> {
log.error("Meeting not found: {}", meetingId);
return new BusinessException(ErrorCode.ENTITY_NOT_FOUND);
});
// 캐시 저장 (TTL: 10분)
try {
cacheService.cacheMeeting(meetingId, meeting, 600);
} catch (Exception e) {
log.warn("Failed to cache meeting: {}", meetingId, e);
}
} else {
log.debug("Cache hit for meeting: {}", meetingId);
} }
// 회의 시작 // 3. 비즈니스 규칙 검증
// TODO: 권한 검증 (생성자 또는 참석자) - userId 파라미터 필요
// 4. 회의 상태 확인 (SCHEDULED만 시작 가능)
if (!"SCHEDULED".equals(meeting.getStatus())) {
log.warn("Meeting is not in SCHEDULED status: meetingId={}, status={}",
meetingId, meeting.getStatus());
if ("IN_PROGRESS".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE,
"이미 진행 중인 회의입니다");
}
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE,
"회의 상태가 올바르지 않습니다");
}
// 5. 회의 세션 생성
String sessionId = UUID.randomUUID().toString();
Session session = Session.builder()
.sessionId(sessionId)
.meetingId(meetingId)
.startedBy(meeting.getOrganizerId()) // TODO: 실제 사용자 ID 사용
.startedAt(LocalDateTime.now())
.status("ACTIVE")
.build();
// 6. 세션 저장
Session savedSession = sessionWriter.save(session);
log.debug("Session created: sessionId={}, meetingId={}", sessionId, meetingId);
// 7. 회의 상태를 IN_PROGRESS로 업데이트
meeting.start(); meeting.start();
// 저장
Meeting updatedMeeting = meetingWriter.save(meeting); Meeting updatedMeeting = meetingWriter.save(meeting);
log.debug("Meeting status updated to IN_PROGRESS: {}", meetingId);
log.info("Meeting started successfully: {}", meetingId); // 8. 캐시 무효화
return updatedMeeting; try {
cacheService.evictCache("meeting:", meetingId);
log.debug("Meeting cache evicted: {}", meetingId);
} catch (Exception e) {
log.warn("Failed to evict meeting cache: {}", meetingId, e);
}
// 9. 회의록 초안 생성 ( 회의록)
String minutesId = UUID.randomUUID().toString();
Minutes minutesDraft = Minutes.builder()
.minutesId(minutesId)
.meetingId(meetingId)
.title(meeting.getTitle() + " - 회의록")
.sections(List.of())
.status("DRAFT")
.version(1)
.createdBy(meeting.getOrganizerId()) // TODO: 실제 사용자 ID 사용
.createdAt(LocalDateTime.now())
.build();
Minutes savedMinutes = minutesWriter.save(minutesDraft);
log.debug("Minutes draft created: minutesId={}, meetingId={}", minutesId, meetingId);
// 세션에 회의록 ID 연결
Session updatedSession = Session.builder()
.sessionId(savedSession.getSessionId())
.meetingId(savedSession.getMeetingId())
.minutesId(minutesId)
.startedBy(savedSession.getStartedBy())
.startedAt(savedSession.getStartedAt())
.status(savedSession.getStatus())
.build();
sessionWriter.save(updatedSession);
// 10. 비동기 이벤트 발행
try {
MeetingStartedEvent event = MeetingStartedEvent.builder()
.meetingId(meetingId)
.sessionId(sessionId)
.title(meeting.getTitle())
.startTime(meeting.getStartedAt())
.organizer(meeting.getOrganizerId())
.participants(meeting.getParticipants())
.minutesId(minutesId)
.eventTime(LocalDateTime.now())
.build();
eventPublisher.publishMeetingStarted(event);
log.debug("MeetingStarted event published: meetingId={}, sessionId={}",
meetingId, sessionId);
} catch (Exception e) {
log.error("Failed to publish MeetingStarted event: meetingId={}", meetingId, e);
// 이벤트 발행 실패는 비즈니스 로직에 영향을 주지 않으므로 계속 진행
}
log.info("Meeting started successfully: meetingId={}, sessionId={}, minutesId={}",
meetingId, sessionId, minutesId);
return updatedSession;
} }
/** /**
* 회의 종료 * 회의 종료 AI 분석
*/ */
@Override @Override
@Transactional @Transactional
public Meeting endMeeting(String meetingId) { public MeetingEndDTO endMeeting(String meetingId) {
log.info("Ending meeting: {}", meetingId); log.info("Ending meeting: {}", meetingId);
// 회의 조회 // 1. 회의 조회
log.debug("Searching for meeting with ID: {}", meetingId);
Meeting meeting = meetingReader.findById(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());
// 회의 상태 검증 // 2. 회의 상태 검증 (SCHEDULED 또는 IN_PROGRESS만 종료 가능)
if (!"IN_PROGRESS".equals(meeting.getStatus())) { if (!"SCHEDULED".equals(meeting.getStatus()) && !"IN_PROGRESS".equals(meeting.getStatus())) {
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); 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.end();
// 저장
Meeting updatedMeeting = meetingWriter.save(meeting); 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); 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();
} }
/** /**
@ -220,16 +533,13 @@ public class MeetingService implements
} }
// 이미 참석자로 등록되었는지 확인 // 이미 참석자로 등록되었는지 확인
if (meeting.getParticipants() != null && meeting.getParticipants().contains(command.email())) { if (participantReader.existsParticipant(command.meetingId(), command.email())) {
log.warn("Email {} is already a participant of meeting {}", command.email(), command.meetingId()); log.warn("Email {} is already a participant of meeting {}", command.email(), command.meetingId());
throw new BusinessException(ErrorCode.DUPLICATE_RESOURCE); throw new BusinessException(ErrorCode.DUPLICATE_RESOURCE);
} }
// 참석자 목록에 추가 // 참석자 저장
meeting.addParticipant(command.email()); participantWriter.saveParticipant(command.meetingId(), command.email());
// 저장
meetingWriter.save(meeting);
// TODO: 실제 이메일 발송 구현 필요 // TODO: 실제 이메일 발송 구현 필요
// 이메일 발송 서비스 호출 // 이메일 발송 서비스 호출

View File

@ -2,11 +2,17 @@ package com.unicorn.hgzero.meeting.biz.service;
import com.unicorn.hgzero.common.exception.BusinessException; import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.common.exception.ErrorCode; import com.unicorn.hgzero.common.exception.ErrorCode;
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.domain.Minutes; import com.unicorn.hgzero.meeting.biz.domain.Minutes;
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO; import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.usecase.in.minutes.*; import com.unicorn.hgzero.meeting.biz.usecase.in.minutes.*;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesReader; import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesWriter; import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesWriter;
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -34,6 +40,10 @@ public class MinutesService implements
private final MinutesReader minutesReader; private final MinutesReader minutesReader;
private final MinutesWriter minutesWriter; private final MinutesWriter minutesWriter;
private final MeetingReader meetingReader;
private final MinutesSectionReader minutesSectionReader;
private final MinutesSectionWriter minutesSectionWriter;
private final CacheService cacheService;
/** /**
* 회의록 생성 * 회의록 생성
@ -118,24 +128,55 @@ public class MinutesService implements
@Override @Override
@Transactional @Transactional
public Minutes finalizeMinutes(String minutesId, String userId) { public Minutes finalizeMinutes(String minutesId, String userId) {
log.info("Finalizing minutes: {}", minutesId); log.info("Finalizing minutes: {} by user: {}", minutesId, userId);
// 회의록 조회 // 1. 회의록 조회
Minutes minutes = minutesReader.findById(minutesId) Minutes minutes = minutesReader.findById(minutesId)
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND)); .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의록을 찾을 수 없습니다."));
// 상태 검증 // 2. 회의 정보 조회
if ("FINALIZED".equals(minutes.getStatus())) { Meeting meeting = meetingReader.findById(minutes.getMeetingId())
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); .orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의 정보를 찾을 수 없습니다."));
}
// 회의록 확정 // 3. 회의록 섹션 조회 설정
List<MinutesSection> sections = minutesSectionReader.findByMinutesIdOrderByOrder(minutesId);
minutes = Minutes.builder()
.minutesId(minutes.getMinutesId())
.meetingId(minutes.getMeetingId())
.title(minutes.getTitle())
.sections(sections)
.status(minutes.getStatus())
.version(minutes.getVersion())
.createdBy(minutes.getCreatedBy())
.createdAt(minutes.getCreatedAt())
.lastModifiedAt(minutes.getLastModifiedAt())
.lastModifiedBy(minutes.getLastModifiedBy())
.finalizedBy(minutes.getFinalizedBy())
.finalizedAt(minutes.getFinalizedAt())
.build();
// 4. 회의록 확정 가능 여부 검증
minutes.validateCanConfirm(meeting, userId);
// 5. 모든 섹션 잠금
minutes.lockAllSections(userId);
// 6. 회의록 확정
minutes.finalize(userId); minutes.finalize(userId);
// 저장 // 7. 회의록 저장
Minutes finalizedMinutes = minutesWriter.save(minutes); Minutes finalizedMinutes = minutesWriter.save(minutes);
log.info("Minutes finalized successfully: {}", minutesId); // 8. 섹션 잠금 상태 저장 (기존 엔티티 조회 업데이트하므로 연관관계 유지됨)
if (sections != null) {
for (MinutesSection section : sections) {
minutesSectionWriter.save(section);
}
}
// 9. 캐시에 저장 (TTL: 10분) - 컨트롤러에서 처리됨
log.info("Minutes finalized successfully: {}, version: {}", minutesId, finalizedMinutes.getVersion());
return finalizedMinutes; return finalizedMinutes;
} }

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

@ -21,8 +21,9 @@ public interface CreateMeetingUseCase {
record CreateMeetingCommand( record CreateMeetingCommand(
String title, String title,
String purpose, String purpose,
String description, String agenda,
LocalDateTime scheduledAt, LocalDateTime scheduledAt,
LocalDateTime endTime,
String location, String location,
String organizerId, String organizerId,
List<String> participants, List<String> participants,

View File

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

View File

@ -1,6 +1,6 @@
package com.unicorn.hgzero.meeting.biz.usecase.in.meeting; package com.unicorn.hgzero.meeting.biz.usecase.in.meeting;
import com.unicorn.hgzero.meeting.biz.domain.Meeting; import com.unicorn.hgzero.meeting.biz.domain.Session;
/** /**
* 회의 시작 UseCase * 회의 시작 UseCase
@ -9,6 +9,8 @@ public interface StartMeetingUseCase {
/** /**
* 회의 시작 * 회의 시작
* @param meetingId 회의 ID
* @return 생성된 세션 정보
*/ */
Meeting startMeeting(String meetingId); Session startMeeting(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

@ -40,4 +40,14 @@ public interface MeetingReader {
* 템플릿 ID로 회의 목록 조회 * 템플릿 ID로 회의 목록 조회
*/ */
List<Meeting> findByTemplateId(String templateId); List<Meeting> findByTemplateId(String templateId);
/**
* 주최자의 특정 시간대 중복 회의 개수 조회
*
* @param organizerId 주최자 ID
* @param startTime 회의 시작 시간
* @param endTime 회의 종료 시간
* @return 중복 회의 개수
*/
long countConflictingMeetings(String organizerId, LocalDateTime startTime, LocalDateTime endTime);
} }

View File

@ -0,0 +1,24 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import java.util.List;
/**
* 참석자 조회 인터페이스
*/
public interface ParticipantReader {
/**
* 회의 ID로 참석자 목록 조회
*/
List<String> findParticipantsByMeetingId(String meetingId);
/**
* 사용자 ID로 참여 회의 목록 조회
*/
List<String> findMeetingsByParticipant(String userId);
/**
* 특정 회의에 특정 사용자가 참석자로 등록되어 있는지 확인
*/
boolean existsParticipant(String meetingId, String userId);
}

View File

@ -0,0 +1,29 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import java.util.List;
/**
* 참석자 저장 인터페이스
*/
public interface ParticipantWriter {
/**
* 회의에 참석자 추가
*/
void saveParticipant(String meetingId, String userId);
/**
* 회의에 참석자 목록 일괄 저장
*/
void saveParticipants(String meetingId, List<String> userIds);
/**
* 회의에서 참석자 삭제
*/
void deleteParticipant(String meetingId, String userId);
/**
* 회의의 모든 참석자 삭제
*/
void deleteAllParticipants(String meetingId);
}

View File

@ -0,0 +1,32 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.Session;
import java.util.List;
import java.util.Optional;
/**
* Session Reader Port
*/
public interface SessionReader {
/**
* ID로 세션 조회
*/
Optional<Session> findById(String sessionId);
/**
* 회의 ID로 세션 목록 조회
*/
List<Session> findByMeetingId(String meetingId);
/**
* 회의 ID로 활성 세션 조회
*/
Optional<Session> findActiveSesionByMeetingId(String meetingId);
/**
* 시작자 ID로 세션 목록 조회
*/
List<Session> findByStartedBy(String startedBy);
}

View File

@ -0,0 +1,19 @@
package com.unicorn.hgzero.meeting.biz.usecase.out;
import com.unicorn.hgzero.meeting.biz.domain.Session;
/**
* Session Writer Port
*/
public interface SessionWriter {
/**
* 세션 저장
*/
Session save(Session session);
/**
* 세션 삭제
*/
void delete(String sessionId);
}

View File

@ -2,60 +2,18 @@ package com.unicorn.hgzero.meeting.infra.cache;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/** /**
* Redis 캐시 설정 * Redis 캐시 설정
*
* RedisConnectionFactory와 RedisTemplate은 RedisConfig에서 정의됨
*/ */
@Configuration @Configuration
@Slf4j @Slf4j
public class CacheConfig { public class CacheConfig {
@Value("${spring.data.redis.host:localhost}")
private String redisHost;
@Value("${spring.data.redis.port:6379}")
private int redisPort;
@Value("${spring.data.redis.database:1}")
private int database;
/**
* Redis 연결 팩토리
*/
@Bean
public RedisConnectionFactory redisConnectionFactory() {
var factory = new LettuceConnectionFactory(redisHost, redisPort);
factory.setDatabase(database);
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}", redisHost, redisPort, database);
return factory;
}
/**
* Redis 템플릿
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// String 직렬화 설정
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());
template.afterPropertiesSet();
log.info("Redis 템플릿 설정 완료");
return template;
}
/** /**
* JSON 직렬화용 ObjectMapper * JSON 직렬화용 ObjectMapper
*/ */

View File

@ -5,6 +5,7 @@ import com.azure.messaging.eventhubs.EventHubProducerClient;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -14,6 +15,7 @@ import org.springframework.context.annotation.Configuration;
*/ */
@Configuration @Configuration
@Slf4j @Slf4j
@org.springframework.boot.autoconfigure.condition.ConditionalOnExpression("'${eventhub.connection-string:}'.length() > 0")
public class EventHubConfig { public class EventHubConfig {
@Value("${eventhub.connection-string}") @Value("${eventhub.connection-string}")

View File

@ -0,0 +1,127 @@
package com.unicorn.hgzero.meeting.infra.config;
import io.lettuce.core.ClientOptions;
import io.lettuce.core.SocketOptions;
import io.lettuce.core.TimeoutOptions;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
/**
* Redis 설정
* Standalone 모드로 연결
*
* - ReadFrom 설정 제거: Master-Replica 자동 탐색 비활성화
* - 로컬 개발 환경에서 Kubernetes 내부 DNS 해석 오류 방지
*/
@Configuration
@Slf4j
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Value("${spring.data.redis.password}")
private String redisPassword;
@Value("${spring.data.redis.database:0}")
private int redisDatabase;
/**
* Lettuce 클라이언트 설정
* - Standalone 모드: ReadFrom 설정 제거로 Master-Replica 자동 탐색 비활성화
* - AutoReconnect: 연결 끊김 자동 재연결
* - DisconnectedBehavior.REJECT_COMMANDS: 연결 끊김 명령 거부
*/
@Bean
public LettuceClientConfiguration lettuceClientConfiguration() {
// 소켓 옵션 설정
SocketOptions socketOptions = SocketOptions.builder()
.connectTimeout(Duration.ofSeconds(10))
.keepAlive(true)
.build();
// 타임아웃 옵션 설정
TimeoutOptions timeoutOptions = TimeoutOptions.builder()
.fixedTimeout(Duration.ofSeconds(10))
.build();
// 클라이언트 옵션 설정
ClientOptions clientOptions = ClientOptions.builder()
.socketOptions(socketOptions)
.timeoutOptions(timeoutOptions)
.autoReconnect(true)
.disconnectedBehavior(ClientOptions.DisconnectedBehavior.REJECT_COMMANDS)
.build();
// Lettuce 클라이언트 설정
// ReadFrom 설정 제거: Standalone 모드로 동작, Master-Replica 자동 탐색 비활성화
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.clientOptions(clientOptions)
.commandTimeout(Duration.ofSeconds(10))
.build();
log.info("Redis Lettuce Client 설정 완료 - Standalone 모드 (Master-Replica 자동 탐색 비활성화)");
return clientConfig;
}
/**
* LettuceConnectionFactory 설정
* Standalone 설정과 Lettuce Client 설정 결합
*/
@Bean
public LettuceConnectionFactory redisConnectionFactory(LettuceClientConfiguration lettuceClientConfiguration) {
// Standalone 설정
RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration();
standaloneConfig.setHostName(redisHost);
standaloneConfig.setPort(redisPort);
standaloneConfig.setPassword(redisPassword);
standaloneConfig.setDatabase(redisDatabase);
// LettuceConnectionFactory 생성
LettuceConnectionFactory factory = new LettuceConnectionFactory(standaloneConfig, lettuceClientConfiguration);
log.info("LettuceConnectionFactory 설정 완료 - Host: {}:{}, Database: {}",
redisHost, redisPort, redisDatabase);
return factory;
}
/**
* RedisTemplate 설정
* String Serializer 사용
*/
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
// String Serializer 사용
StringRedisSerializer stringSerializer = new StringRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setValueSerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(stringSerializer);
template.afterPropertiesSet();
log.info("RedisTemplate 설정 완료");
return template;
}
}

View File

@ -12,11 +12,15 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.firewall.HttpFirewall;
import org.springframework.security.web.firewall.StrictHttpFirewall;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays; import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
/** /**
* Spring Security 설정 * Spring Security 설정
@ -50,7 +54,8 @@ public class SecurityConfig {
// Meeting API endpoints (for testing) // Meeting API endpoints (for testing)
.requestMatchers("/api/meetings/**").permitAll() .requestMatchers("/api/meetings/**").permitAll()
// All other requests require authentication // All other requests require authentication
.anyRequest().authenticated() // .anyRequest().authenticated()
.anyRequest().permitAll()
) )
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class) UsernamePasswordAuthenticationFilter.class)
@ -71,7 +76,8 @@ public class SecurityConfig {
// 허용할 헤더 // 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList( configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept", "Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers" "Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers",
"X-User-Id", "X-User-Name", "X-User-Email"
)); ));
// 자격 증명 허용 // 자격 증명 허용
@ -84,4 +90,24 @@ public class SecurityConfig {
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);
return source; return source;
} }
/**
* HttpFirewall 설정
* 한글을 포함한 모든 문자를 헤더 값으로 허용
*/
@Bean
public HttpFirewall allowUrlEncodedSlashHttpFirewall() {
StrictHttpFirewall firewall = new StrictHttpFirewall();
// 한글을 포함한 모든 문자를 허용하도록 설정
firewall.setAllowedHeaderValues(header -> true);
// URL 인코딩된 슬래시 허용
firewall.setAllowUrlEncodedSlash(true);
// 세미콜론 허용
firewall.setAllowSemicolon(true);
return firewall;
}
} }

View File

@ -32,6 +32,36 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
HttpServletResponse response, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
// 1. X-User-* 헤더를 통한 인증 (개발/테스트용)
String headerUserId = request.getHeader("X-User-Id");
String headerUserName = request.getHeader("X-User-Name");
String headerUserEmail = request.getHeader("X-User-Email");
if (StringUtils.hasText(headerUserId)) {
// X-User-* 헤더가 있으면 이를 사용하여 인증
UserPrincipal userPrincipal = UserPrincipal.builder()
.userId(headerUserId)
.username(headerUserName != null ? headerUserName : "unknown")
.email(headerUserEmail)
.authority("USER")
.build();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userPrincipal,
null,
Collections.singletonList(new SimpleGrantedAuthority("USER"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("헤더 기반 인증된 사용자: {} ({})", userPrincipal.getUsername(), headerUserId);
filterChain.doFilter(request, response);
return;
}
// 2. JWT 토큰을 통한 인증
String token = jwtTokenProvider.resolveToken(request); String token = jwtTokenProvider.resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
@ -69,7 +99,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("인증된 사용자: {} ({})", userPrincipal.getUsername(), userId); log.debug("JWT 기반 인증된 사용자: {} ({})", userPrincipal.getUsername(), userId);
} }
} }

View File

@ -23,6 +23,11 @@ public class UserPrincipal {
*/ */
private final String username; private final String username;
/**
* 사용자 이메일
*/
private final String email;
/** /**
* 사용자 권한 * 사용자 권한
*/ */

View File

@ -1,8 +1,6 @@
package com.unicorn.hgzero.meeting.infra.controller; package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse; 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 com.unicorn.hgzero.meeting.infra.dto.response.DashboardResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; 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.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; 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 * 대시보드 REST API Controller
* 사용자별 맞춤 대시보드 데이터 제공 * 사용자별 맞춤 대시보드 데이터 제공
@ -25,17 +28,15 @@ import org.springframework.web.bind.annotation.*;
@Slf4j @Slf4j
public class DashboardController { public class DashboardController {
private final GetDashboardUseCase getDashboardUseCase;
/** /**
* 대시보드 데이터 조회 * 대시보드 데이터 조회 ( 데이터)
* *
* @param userId 사용자 ID * @param userId 사용자 ID
* @return 대시보드 데이터 * @return 대시보드 데이터
*/ */
@Operation( @Operation(
summary = "대시보드 데이터 조회", summary = "대시보드 데이터 조회",
description = "사용자별 맞춤 대시보드 정보를 조회합니다. 예정된 회의 목록, 진행 중 Todo 목록, 최근 회의록 목록, 통계 정보를 포함합니다.", description = "사용자별 맞춤 대시보드 정보를 조회합니다. 예정된 회의 목록, 최근 회의록 목록, 통계 정보를 포함합니다.",
security = @SecurityRequirement(name = "bearerAuth") security = @SecurityRequirement(name = "bearerAuth")
) )
@GetMapping @GetMapping
@ -49,12 +50,80 @@ public class DashboardController {
log.info("대시보드 데이터 조회 요청 - userId: {}", userId); log.info("대시보드 데이터 조회 요청 - userId: {}", userId);
var dashboardData = getDashboardUseCase.getDashboard(userId); // 데이터 생성
var dashboardDTO = DashboardDTO.from(dashboardData); DashboardResponse mockResponse = createMockDashboardData();
var response = DashboardResponse.from(dashboardDTO);
log.info("대시보드 데이터 조회 완료 - userId: {}", userId); 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.request.SelectTemplateRequest;
import com.unicorn.hgzero.meeting.infra.dto.response.InviteParticipantResponse; 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.MeetingResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.MeetingEndResponse;
import com.unicorn.hgzero.meeting.infra.dto.response.SessionResponse; import com.unicorn.hgzero.meeting.infra.dto.response.SessionResponse;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
@ -20,6 +21,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import java.util.List;
/** /**
* 회의 관리 REST API Controller * 회의 관리 REST API Controller
@ -38,6 +40,7 @@ public class MeetingController {
private final GetMeetingUseCase getMeetingUseCase; private final GetMeetingUseCase getMeetingUseCase;
private final CancelMeetingUseCase cancelMeetingUseCase; private final CancelMeetingUseCase cancelMeetingUseCase;
private final InviteParticipantUseCase inviteParticipantUseCase; private final InviteParticipantUseCase inviteParticipantUseCase;
private final ApplyTemplateUseCase applyTemplateUseCase;
/** /**
* 회의 예약 * 회의 예약
@ -69,6 +72,7 @@ public class MeetingController {
request.getPurpose(), request.getPurpose(),
request.getAgenda(), request.getAgenda(),
request.getStartTime(), request.getStartTime(),
request.getEndTime(),
request.getLocation(), request.getLocation(),
userId, userId,
request.getParticipants(), request.getParticipants(),
@ -112,11 +116,17 @@ public class MeetingController {
log.info("템플릿 적용 요청 - meetingId: {}, templateId: {}", meetingId, request.getTemplateId()); 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 meetingDTO = MeetingDTO.from(meetingData);
var response = MeetingResponse.from(meetingDTO); var response = MeetingResponse.from(meetingDTO);
log.info("템플릿 적용 완료 - meetingId: {}", meetingId); log.info("템플릿 적용 완료 - meetingId: {}, templateId: {}", meetingId, request.getTemplateId());
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} }
@ -146,12 +156,14 @@ public class MeetingController {
log.info("회의 시작 요청 - meetingId: {}, userId: {}", meetingId, userId); log.info("회의 시작 요청 - meetingId: {}, userId: {}", meetingId, userId);
var sessionData = startMeetingUseCase.startMeeting(meetingId); // meeting id 유효성 검증 필요
var response = SessionResponse.from(sessionData);
log.info("회의 시작 완료 - meetingId: {}", meetingId); var session = startMeetingUseCase.startMeeting(meetingId);
var response = SessionResponse.from(session, "ws://localhost:8080/ws/collaboration");
return ResponseEntity.ok(ApiResponse.success(response)); log.info("회의 시작 완료 - meetingId: {}, sessionId: {}", meetingId, session.getSessionId());
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(response));
} }
/** /**
@ -159,15 +171,15 @@ public class MeetingController {
* *
* @param meetingId 회의 ID * @param meetingId 회의 ID
* @param userId 사용자 ID * @param userId 사용자 ID
* @return 회의 정보 * @return 회의 종료 결과
*/ */
@Operation( @Operation(
summary = "회의 종료", summary = "회의 종료",
description = "진행 중인 회의를 종료하고 회의록 작성을 완료합니다. 자동 Todo 추출 및 알림 발송이 수행됩니다.", description = "진행 중인 회의를 종료하고 AI 분석을 통해 회의록을 생성합니다. 주요 키워드 추출, 안건별 요약, Todo 자동 추출이 수행됩니다.",
security = @SecurityRequirement(name = "bearerAuth") security = @SecurityRequirement(name = "bearerAuth")
) )
@PostMapping("/{meetingId}/end") @PostMapping("/{meetingId}/end")
public ResponseEntity<ApiResponse<MeetingResponse>> endMeeting( public ResponseEntity<ApiResponse<MeetingEndResponse>> endMeeting(
@Parameter(description = "회의 ID", required = true) @Parameter(description = "회의 ID", required = true)
@PathVariable String meetingId, @PathVariable String meetingId,
@Parameter(description = "사용자 ID", required = true) @Parameter(description = "사용자 ID", required = true)
@ -179,15 +191,102 @@ public class MeetingController {
log.info("회의 종료 요청 - meetingId: {}, userId: {}", meetingId, userId); log.info("회의 종료 요청 - meetingId: {}, userId: {}", meetingId, userId);
var meetingData = endMeetingUseCase.endMeeting(meetingId); // Mock 데이터로 응답 (개발용)
var meetingDTO = MeetingDTO.from(meetingData); var response = createMockMeetingEndResponse(meetingId);
var response = MeetingResponse.from(meetingDTO);
log.info("회의 종료 완료 - meetingId: {}", meetingId); log.info("회의 종료 완료 (Mock) - meetingId: {}", meetingId);
return ResponseEntity.ok(ApiResponse.success(response)); 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,6 +1,7 @@
package com.unicorn.hgzero.meeting.infra.controller; package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse; import com.unicorn.hgzero.common.dto.ApiResponse;
import com.unicorn.hgzero.common.exception.BusinessException;
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO; import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
import com.unicorn.hgzero.meeting.biz.service.MinutesService; import com.unicorn.hgzero.meeting.biz.service.MinutesService;
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService; import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
@ -202,8 +203,16 @@ public class MinutesController {
// 응답 DTO 생성 // 응답 DTO 생성
MinutesDetailResponse response = convertToMinutesDetailResponse(finalizedMinutes); MinutesDetailResponse response = convertToMinutesDetailResponse(finalizedMinutes);
// 캐시 무효화 // 캐시 저장 (TTL: 10분)
cacheService.evictCacheMinutesDetail(minutesId); try {
cacheService.cacheMinutesDetail(minutesId, response);
log.debug("캐시에 확정된 회의록 저장 완료 - minutesId: {}", minutesId);
} catch (Exception cacheEx) {
log.warn("회의록 캐시 저장 실패 - minutesId: {}", minutesId, cacheEx);
// 캐시 저장 실패는 무시하고 진행
}
// 캐시 무효화 (목록 캐시)
cacheService.evictCacheMinutesList(userId); cacheService.evictCacheMinutesList(userId);
// 회의록 확정 이벤트 발행 // 회의록 확정 이벤트 발행
@ -212,6 +221,10 @@ public class MinutesController {
log.info("회의록 확정 성공 - minutesId: {}", minutesId); log.info("회의록 확정 성공 - minutesId: {}", minutesId);
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
} catch (BusinessException e) {
log.error("회의록 확정 비즈니스 오류 - minutesId: {}, error: {}", minutesId, e.getMessage());
return ResponseEntity.status(e.getErrorCode().getHttpStatus())
.body(ApiResponse.errorWithType(e.getMessage()));
} catch (Exception e) { } catch (Exception e) {
log.error("회의록 확정 실패 - minutesId: {}", minutesId, e); log.error("회의록 확정 실패 - minutesId: {}", minutesId, e);
return ResponseEntity.badRequest() return ResponseEntity.badRequest()

View File

@ -1,13 +1,8 @@
package com.unicorn.hgzero.meeting.infra.controller; package com.unicorn.hgzero.meeting.infra.controller;
import com.unicorn.hgzero.common.dto.ApiResponse; 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.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.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -15,12 +10,14 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; 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.List;
import java.util.stream.Collectors;
/** /**
* 템플릿 관리API Controller * 템플릿 관리API Controller
* 템플릿 목록 조회, 상세 조회 기능 * 고정된 템플릿 목록을 제공합니다
*/ */
@RestController @RestController
@RequestMapping("/api/templates") @RequestMapping("/api/templates")
@ -29,11 +26,8 @@ import java.util.stream.Collectors;
@Tag(name = "Template", description = "템플릿 관리 API") @Tag(name = "Template", description = "템플릿 관리 API")
public class TemplateController { public class TemplateController {
private final TemplateService templateService;
private final CacheService cacheService;
/** /**
* 템플릿 목록 조회 * 템플릿 목록 조회 (고정 데이터 반환)
* GET /api/templates * GET /api/templates
*/ */
@GetMapping @GetMapping
@ -45,40 +39,19 @@ public class TemplateController {
}) })
public ResponseEntity<ApiResponse<TemplateListResponse>> getTemplateList( public ResponseEntity<ApiResponse<TemplateListResponse>> getTemplateList(
@RequestHeader("X-User-Id") String userId, @RequestHeader("X-User-Id") String userId,
@RequestHeader("X-User-Name") String userName, @RequestHeader("X-User-Name") String userName) {
@Parameter(description = "템플릿 카테고리") @RequestParam(required = false) String category,
@Parameter(description = "활성 상태 (true: 활성, false: 비활성)") @RequestParam(required = false) Boolean isActive) {
log.info("템플릿 목록 조회 요청 - userId: {}, category: {}, isActive: {}", log.info("템플릿 목록 조회 요청 - userId: {}", userId);
userId, category, isActive);
try { try {
// 캐시 확인 // 고정된 템플릿 데이터 생성
String cacheKey = String.format("templates:list:%s:%s", List<TemplateListResponse.TemplateItem> templateItems = createFixedTemplates();
(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());
TemplateListResponse response = TemplateListResponse.builder() TemplateListResponse response = TemplateListResponse.builder()
.templateList(templateItems) .templateList(templateItems)
.totalCount(templateItems.size()) .totalCount(templateItems.size())
.build(); .build();
// 캐시 저장
cacheService.cacheTemplateList(cacheKey, response);
log.info("템플릿 목록 조회 성공 - count: {}", templateItems.size()); log.info("템플릿 목록 조회 성공 - count: {}", templateItems.size());
return ResponseEntity.ok(ApiResponse.success(response)); return ResponseEntity.ok(ApiResponse.success(response));
@ -90,99 +63,103 @@ public class TemplateController {
} }
/** /**
* 템플릿 상세 조회 * 고정된 템플릿 데이터 생성
* GET /api/templates/{templateId}
*/ */
@GetMapping("/{templateId}") private List<TemplateListResponse.TemplateItem> createFixedTemplates() {
@Operation(summary = "템플릿 상세 조회", description = "템플릿 상세 정보를 조회합니다") List<TemplateListResponse.TemplateItem> templates = new ArrayList<>();
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); // 일반 회의 템플릿
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());
try { // 스크럼 회의 템플릿
// 캐시 확인 templates.add(TemplateListResponse.TemplateItem.builder()
TemplateDetailResponse cachedResponse = cacheService.getCachedTemplateDetail(templateId); .templateId("scrum")
if (cachedResponse != null) { .name("스크럼 회의")
log.debug("캐시된 템플릿 상세 반환 - templateId: {}", templateId); .description("데일리 스탠드업 형식")
return ResponseEntity.ok(ApiResponse.success(cachedResponse)); .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;
} }
// 템플릿 조회 /**
TemplateDTO templateDTO = templateService.getTemplateById(templateId); * 템플릿 섹션 정보 생성 헬퍼 메서드
*/
// 응답 DTO 생성 private TemplateListResponse.TemplateSectionInfo createSectionInfo(
TemplateDetailResponse response = convertToTemplateDetailResponse(templateDTO); String title, String description, int orderIndex, boolean isRequired) {
return TemplateListResponse.TemplateSectionInfo.builder()
// 캐시 저장 .title(title)
cacheService.cacheTemplateDetail(templateId, response); .description(description)
.orderIndex(orderIndex)
log.info("템플릿 상세 조회 성공 - templateId: {}", templateId); .isRequired(isRequired)
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("템플릿 상세 조회 실패 - templateId: {}", templateId, e);
return ResponseEntity.badRequest()
.body(ApiResponse.errorWithType("템플릿 상세 조회에 실패했습니다"));
}
}
// 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)
.build(); .build();
} }
} }

View File

@ -21,6 +21,7 @@ import java.util.List;
public class CreateMeetingRequest { public class CreateMeetingRequest {
@NotBlank(message = "회의 제목은 필수입니다") @NotBlank(message = "회의 제목은 필수입니다")
@jakarta.validation.constraints.Size(max = 100, message = "회의 제목은 100자를 초과할 수 없습니다")
@Schema(description = "회의 제목", example = "Q1 전략 회의", required = true) @Schema(description = "회의 제목", example = "Q1 전략 회의", required = true)
private String title; private String title;

View File

@ -17,9 +17,7 @@ import jakarta.validation.constraints.NotBlank;
public class SelectTemplateRequest { public class SelectTemplateRequest {
@NotBlank(message = "템플릿 ID는 필수입니다") @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; 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

@ -21,6 +21,12 @@ public class SessionResponse {
@Schema(description = "회의 ID", example = "550e8400-e29b-41d4-a716-446655440000") @Schema(description = "회의 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private final String meetingId; private final String meetingId;
@Schema(description = "회의록 ID", example = "minutes-001")
private final String minutesId;
@Schema(description = "세션 상태", example = "IN_PROGRESS")
private final String status;
@Schema(description = "WebSocket URL", example = "ws://localhost:8080/ws/collaboration") @Schema(description = "WebSocket URL", example = "ws://localhost:8080/ws/collaboration")
private final String websocketUrl; private final String websocketUrl;
@ -40,10 +46,28 @@ public class SessionResponse {
return SessionResponse.builder() return SessionResponse.builder()
.sessionId("session-" + meeting.getMeetingId()) .sessionId("session-" + meeting.getMeetingId())
.meetingId(meeting.getMeetingId()) .meetingId(meeting.getMeetingId())
.minutesId(null) // 실제로는 세션에서 가져와야
.status(meeting.getStatus())
.websocketUrl("ws://localhost:8080/ws/collaboration") .websocketUrl("ws://localhost:8080/ws/collaboration")
.sessionToken("session-token-" + System.currentTimeMillis()) .sessionToken("session-token-" + System.currentTimeMillis())
.startedAt(meeting.getStartedAt() != null ? meeting.getStartedAt() : LocalDateTime.now()) .startedAt(meeting.getStartedAt() != null ? meeting.getStartedAt() : LocalDateTime.now())
.expiresAt(LocalDateTime.now().plusHours(4)) .expiresAt(LocalDateTime.now().plusHours(4))
.build(); .build();
} }
/**
* Session 객체로부터 SessionResponse 생성
*/
public static SessionResponse from(com.unicorn.hgzero.meeting.biz.domain.Session session, String websocketUrl) {
return SessionResponse.builder()
.sessionId(session.getSessionId())
.meetingId(session.getMeetingId())
.minutesId(session.getMinutesId())
.status(session.getStatus())
.websocketUrl(websocketUrl != null ? websocketUrl : "ws://localhost:8080/ws/collaboration")
.sessionToken("session-token-" + System.currentTimeMillis())
.startedAt(session.getStartedAt())
.expiresAt(session.getStartedAt().plusHours(4))
.build();
}
} }

View File

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

View File

@ -18,6 +18,11 @@ public class MeetingStartedEvent {
*/ */
private final String meetingId; private final String meetingId;
/**
* 세션 ID
*/
private final String sessionId;
/** /**
* 회의 제목 * 회의 제목
*/ */

View File

@ -22,6 +22,7 @@ import org.springframework.stereotype.Component;
*/ */
@Component @Component
@Slf4j @Slf4j
@org.springframework.boot.autoconfigure.condition.ConditionalOnBean(name = "eventProducer")
public class EventHubPublisher implements EventPublisher { public class EventHubPublisher implements EventPublisher {
private final EventHubProducerClient eventProducer; private final EventHubProducerClient eventProducer;
@ -57,7 +58,7 @@ public class EventHubPublisher implements EventPublisher {
@Override @Override
public void publishNotificationRequest(NotificationRequestEvent event) { public void publishNotificationRequest(NotificationRequestEvent event) {
publishEvent(event, event.getRecipientId(), publishEvent(event, event.getRecipientEmail(),
EventHubConstants.TOPIC_NOTIFICATION, EventHubConstants.TOPIC_NOTIFICATION,
EventHubConstants.EVENT_TYPE_NOTIFICATION_REQUEST); EventHubConstants.EVENT_TYPE_NOTIFICATION_REQUEST);
} }
@ -120,6 +121,34 @@ public class EventHubPublisher implements EventPublisher {
EventHubConstants.EVENT_TYPE_MINUTES_FINALIZED); EventHubConstants.EVENT_TYPE_MINUTES_FINALIZED);
} }
@Override
public void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, java.util.List<String> participants,
String organizerId, String organizerName) {
// 참석자에게 개별 알림 이벤트 발행
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);
}
log.info("회의 생성 알림 발행 완료 - meetingId: {}, participants count: {}",
meetingId, participants.size());
}
/** /**
* 이벤트 발행 공통 메서드 * 이벤트 발행 공통 메서드
* *

View File

@ -6,6 +6,7 @@ import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent; import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 이벤트 발행 인터페이스 * 이벤트 발행 인터페이스
@ -59,4 +60,19 @@ public interface EventPublisher {
* 회의록 확정 이벤트 발행 (편의 메서드) * 회의록 확정 이벤트 발행 (편의 메서드)
*/ */
void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName); void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName);
/**
* 회의 생성 알림 발행 (편의 메서드)
* 참석자에게 회의 초대 이메일 발송
*
* @param meetingId 회의 ID
* @param title 회의 제목
* @param startTime 회의 시작 시간
* @param location 회의 장소
* @param participants 참석자 이메일 목록
* @param organizerId 주최자 ID
* @param organizerName 주최자 이름
*/
void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, List<String> participants, String organizerId, String organizerName);
} }

View File

@ -0,0 +1,69 @@
package com.unicorn.hgzero.meeting.infra.event.publisher;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingStartedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.MeetingEndedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.TodoAssignedEvent;
import com.unicorn.hgzero.meeting.infra.event.dto.NotificationRequestEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* No-Op EventPublisher 구현체
* EventHub가 설정되지 않은 경우 사용되는 더미 구현체
*/
@Component
@Primary
@ConditionalOnMissingBean(name = "eventProducer")
@Slf4j
public class NoOpEventPublisher implements EventPublisher {
@Override
public void publishMeetingStarted(MeetingStartedEvent event) {
log.debug("[NoOp] Meeting started event: {}", event.getMeetingId());
}
@Override
public void publishMeetingEnded(MeetingEndedEvent event) {
log.debug("[NoOp] Meeting ended event: {}", event.getMeetingId());
}
@Override
public void publishTodoAssigned(TodoAssignedEvent event) {
log.debug("[NoOp] Todo assigned event: {}", event.getTodoId());
}
@Override
public void publishNotificationRequest(NotificationRequestEvent event) {
log.debug("[NoOp] Notification request event: {}", event.getRecipientEmail());
}
@Override
public void publishTodoAssigned(String todoId, String title, String assigneeId, String assigneeName,
String assignedBy, String assignedByName, LocalDate dueDate) {
log.debug("[NoOp] Todo assigned: todoId={}, title={}", todoId, title);
}
@Override
public void publishTodoCompleted(String todoId, String title, String assigneeId, String assigneeName,
String completedBy, String completedByName) {
log.debug("[NoOp] Todo completed: todoId={}, title={}", todoId, title);
}
@Override
public void publishMinutesFinalized(String minutesId, String title, String finalizedBy, String finalizedByName) {
log.debug("[NoOp] Minutes finalized: minutesId={}, title={}", minutesId, title);
}
@Override
public void publishMeetingCreated(String meetingId, String title, LocalDateTime startTime,
String location, List<String> participants, String organizerId, String organizerName) {
log.debug("[NoOp] Meeting created: meetingId={}, title={}, participants={}",
meetingId, title, participants.size());
}
}

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

@ -3,6 +3,7 @@ package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.Meeting; import com.unicorn.hgzero.meeting.biz.domain.Meeting;
import com.unicorn.hgzero.meeting.biz.usecase.out.MeetingReader; 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.MeetingWriter;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity; import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository; import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingJpaRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -24,48 +25,72 @@ import java.util.stream.Collectors;
public class MeetingGateway implements MeetingReader, MeetingWriter { public class MeetingGateway implements MeetingReader, MeetingWriter {
private final MeetingJpaRepository meetingJpaRepository; private final MeetingJpaRepository meetingJpaRepository;
private final ParticipantReader participantReader;
@Override @Override
public Optional<Meeting> findById(String meetingId) { public Optional<Meeting> findById(String meetingId) {
return meetingJpaRepository.findById(meetingId) return meetingJpaRepository.findById(meetingId)
.map(MeetingEntity::toDomain); .map(this::enrichWithParticipants);
} }
@Override @Override
public List<Meeting> findByOrganizerId(String organizerId) { public List<Meeting> findByOrganizerId(String organizerId) {
return meetingJpaRepository.findByOrganizerId(organizerId).stream() return meetingJpaRepository.findByOrganizerId(organizerId).stream()
.map(MeetingEntity::toDomain) .map(this::enrichWithParticipants)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override @Override
public List<Meeting> findByStatus(String status) { public List<Meeting> findByStatus(String status) {
return meetingJpaRepository.findByStatus(status).stream() return meetingJpaRepository.findByStatus(status).stream()
.map(MeetingEntity::toDomain) .map(this::enrichWithParticipants)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override @Override
public List<Meeting> findByOrganizerIdAndStatus(String organizerId, String status) { public List<Meeting> findByOrganizerIdAndStatus(String organizerId, String status) {
return meetingJpaRepository.findByOrganizerIdAndStatus(organizerId, status).stream() return meetingJpaRepository.findByOrganizerIdAndStatus(organizerId, status).stream()
.map(MeetingEntity::toDomain) .map(this::enrichWithParticipants)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override @Override
public List<Meeting> findByScheduledTimeBetween(LocalDateTime startTime, LocalDateTime endTime) { public List<Meeting> findByScheduledTimeBetween(LocalDateTime startTime, LocalDateTime endTime) {
return meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream() return meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
.map(MeetingEntity::toDomain) .map(this::enrichWithParticipants)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
@Override @Override
public List<Meeting> findByTemplateId(String templateId) { public List<Meeting> findByTemplateId(String templateId) {
return meetingJpaRepository.findByTemplateId(templateId).stream() return meetingJpaRepository.findByTemplateId(templateId).stream()
.map(MeetingEntity::toDomain) .map(this::enrichWithParticipants)
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
/**
* Meeting 엔티티를 도메인으로 변환하면서 participants 정보 추가
*/
private Meeting enrichWithParticipants(MeetingEntity entity) {
Meeting meeting = entity.toDomain();
List<String> participants = participantReader.findParticipantsByMeetingId(entity.getMeetingId());
return Meeting.builder()
.meetingId(meeting.getMeetingId())
.title(meeting.getTitle())
.purpose(meeting.getPurpose())
.description(meeting.getDescription())
.scheduledAt(meeting.getScheduledAt())
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.startedAt(meeting.getStartedAt())
.endedAt(meeting.getEndedAt())
.status(meeting.getStatus())
.organizerId(meeting.getOrganizerId())
.participants(participants)
.templateId(meeting.getTemplateId())
.build();
}
@Override @Override
public Meeting save(Meeting meeting) { public Meeting save(Meeting meeting) {
MeetingEntity entity = MeetingEntity.fromDomain(meeting); MeetingEntity entity = MeetingEntity.fromDomain(meeting);
@ -77,4 +102,9 @@ public class MeetingGateway implements MeetingReader, MeetingWriter {
public void delete(String meetingId) { public void delete(String meetingId) {
meetingJpaRepository.deleteById(meetingId); meetingJpaRepository.deleteById(meetingId);
} }
@Override
public long countConflictingMeetings(String organizerId, LocalDateTime startTime, LocalDateTime endTime) {
return meetingJpaRepository.countConflictingMeetings(organizerId, startTime, endTime);
}
} }

View File

@ -66,7 +66,24 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
@Override @Override
public Minutes save(Minutes minutes) { public Minutes save(Minutes minutes) {
MinutesEntity entity = MinutesEntity.fromDomain(minutes); // 기존 엔티티 조회 (update) 또는 새로 생성 (insert)
MinutesEntity entity = minutesJpaRepository.findById(minutes.getMinutesId())
.orElse(null);
if (entity != null) {
// 기존 엔티티 업데이트 (연관관계 유지)
if (minutes.getStatus() != null && minutes.getStatus().equals("FINALIZED")) {
entity.finalize(minutes.getFinalizedBy());
}
if (minutes.getVersion() != null) {
entity.updateVersion();
}
// sections는 cascade로 자동 업데이트됨
} else {
// 엔티티 생성
entity = MinutesEntity.fromDomain(minutes);
}
MinutesEntity savedEntity = minutesJpaRepository.save(entity); MinutesEntity savedEntity = minutesJpaRepository.save(entity);
return savedEntity.toDomain(); return savedEntity.toDomain();
} }

View File

@ -67,7 +67,25 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
@Override @Override
public MinutesSection save(MinutesSection section) { public MinutesSection save(MinutesSection section) {
MinutesSectionEntity entity = MinutesSectionEntity.fromDomain(section); // 기존 엔티티 조회 (update) 또는 새로 생성 (insert)
MinutesSectionEntity entity = sectionJpaRepository.findById(section.getSectionId())
.orElse(null);
if (entity != null) {
// 기존 엔티티 업데이트 (minutes 연관관계 유지)
if (section.getLocked() != null && section.getLocked()) {
entity.lock(section.getLockedBy());
} else if (section.getLocked() != null && !section.getLocked()) {
entity.unlock();
}
if (section.getVerified() != null && section.getVerified()) {
entity.verify();
}
} else {
// 엔티티 생성
entity = MinutesSectionEntity.fromDomain(section);
}
MinutesSectionEntity savedEntity = sectionJpaRepository.save(entity); MinutesSectionEntity savedEntity = sectionJpaRepository.save(entity);
return savedEntity.toDomain(); return savedEntity.toDomain();
} }

View File

@ -0,0 +1,101 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.ParticipantWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.MeetingParticipantJpaRepository;
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.stream.Collectors;
/**
* 참석자 Gateway 구현체
* ParticipantReader, ParticipantWriter 인터페이스 구현
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ParticipantGateway implements ParticipantReader, ParticipantWriter {
private final MeetingParticipantJpaRepository participantRepository;
@Override
@Transactional(readOnly = true)
public List<String> findParticipantsByMeetingId(String meetingId) {
return participantRepository.findByMeetingId(meetingId).stream()
.map(MeetingParticipantEntity::getUserId)
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public List<String> findMeetingsByParticipant(String userId) {
return participantRepository.findByUserId(userId).stream()
.map(MeetingParticipantEntity::getMeetingId)
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public boolean existsParticipant(String meetingId, String userId) {
return participantRepository.existsByMeetingIdAndUserId(meetingId, userId);
}
@Override
@Transactional
public void saveParticipant(String meetingId, String userId) {
if (!participantRepository.existsByMeetingIdAndUserId(meetingId, userId)) {
MeetingParticipantEntity participant = MeetingParticipantEntity.builder()
.meetingId(meetingId)
.userId(userId)
.invitationStatus("PENDING")
.attended(false)
.build();
participantRepository.save(participant);
log.debug("Participant saved: meetingId={}, userId={}", meetingId, userId);
} else {
log.debug("Participant already exists: meetingId={}, userId={}", meetingId, userId);
}
}
@Override
@Transactional
public void saveParticipants(String meetingId, List<String> userIds) {
if (userIds == null || userIds.isEmpty()) {
return;
}
List<MeetingParticipantEntity> participants = userIds.stream()
.filter(userId -> !participantRepository.existsByMeetingIdAndUserId(meetingId, userId))
.map(userId -> MeetingParticipantEntity.builder()
.meetingId(meetingId)
.userId(userId)
.invitationStatus("PENDING")
.attended(false)
.build())
.collect(Collectors.toList());
if (!participants.isEmpty()) {
participantRepository.saveAll(participants);
log.debug("Participants saved: meetingId={}, count={}", meetingId, participants.size());
}
}
@Override
@Transactional
public void deleteParticipant(String meetingId, String userId) {
participantRepository.deleteById(new com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantId(meetingId, userId));
log.debug("Participant deleted: meetingId={}, userId={}", meetingId, userId);
}
@Override
@Transactional
public void deleteAllParticipants(String meetingId) {
participantRepository.deleteByMeetingId(meetingId);
log.debug("All participants deleted: meetingId={}", meetingId);
}
}

View File

@ -0,0 +1,64 @@
package com.unicorn.hgzero.meeting.infra.gateway;
import com.unicorn.hgzero.meeting.biz.domain.Session;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionReader;
import com.unicorn.hgzero.meeting.biz.usecase.out.SessionWriter;
import com.unicorn.hgzero.meeting.infra.gateway.entity.SessionEntity;
import com.unicorn.hgzero.meeting.infra.gateway.repository.SessionJpaRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 세션 Gateway 구현체
* SessionReader, SessionWriter 인터페이스 구현
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SessionGateway implements SessionReader, SessionWriter {
private final SessionJpaRepository sessionJpaRepository;
@Override
public Optional<Session> findById(String sessionId) {
return sessionJpaRepository.findById(sessionId)
.map(SessionEntity::toDomain);
}
@Override
public List<Session> findByMeetingId(String meetingId) {
return sessionJpaRepository.findByMeetingId(meetingId).stream()
.map(SessionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Optional<Session> findActiveSesionByMeetingId(String meetingId) {
return sessionJpaRepository.findActiveSessionByMeetingId(meetingId)
.map(SessionEntity::toDomain);
}
@Override
public List<Session> findByStartedBy(String startedBy) {
return sessionJpaRepository.findByStartedBy(startedBy).stream()
.map(SessionEntity::toDomain)
.collect(Collectors.toList());
}
@Override
public Session save(Session session) {
SessionEntity entity = SessionEntity.fromDomain(session);
SessionEntity savedEntity = sessionJpaRepository.save(entity);
return savedEntity.toDomain();
}
@Override
public void delete(String sessionId) {
sessionJpaRepository.deleteById(sessionId);
}
}

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

@ -9,7 +9,7 @@ import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -31,12 +31,21 @@ public class MeetingEntity extends BaseTimeEntity {
@Column(name = "title", length = 200, nullable = false) @Column(name = "title", length = 200, nullable = false)
private String title; private String title;
@Column(name = "purpose", length = 500)
private String purpose;
@Column(name = "description", columnDefinition = "TEXT") @Column(name = "description", columnDefinition = "TEXT")
private String description; private String description;
@Column(name = "scheduled_at", nullable = false) @Column(name = "scheduled_at", nullable = false)
private LocalDateTime scheduledAt; private LocalDateTime scheduledAt;
@Column(name = "end_time")
private LocalDateTime endTime;
@Column(name = "location", length = 200)
private String location;
@Column(name = "started_at") @Column(name = "started_at")
private LocalDateTime startedAt; private LocalDateTime startedAt;
@ -50,23 +59,34 @@ public class MeetingEntity extends BaseTimeEntity {
@Column(name = "organizer_id", length = 50, nullable = false) @Column(name = "organizer_id", length = 50, nullable = false)
private String organizerId; private String organizerId;
@Column(name = "participants", columnDefinition = "TEXT")
private String participants;
@Column(name = "template_id", length = 50) @Column(name = "template_id", length = 50)
private String templateId; private String templateId;
/**
* 회의 참석자 목록 (일대다 관계)
*/
@OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<MeetingParticipantEntity> participants = new ArrayList<>();
public Meeting toDomain() { public Meeting toDomain() {
return Meeting.builder() return Meeting.builder()
.meetingId(this.meetingId) .meetingId(this.meetingId)
.title(this.title) .title(this.title)
.purpose(this.purpose)
.description(this.description) .description(this.description)
.scheduledAt(this.scheduledAt) .scheduledAt(this.scheduledAt)
.endTime(this.endTime)
.location(this.location)
.startedAt(this.startedAt) .startedAt(this.startedAt)
.endedAt(this.endedAt) .endedAt(this.endedAt)
.status(this.status) .status(this.status)
.organizerId(this.organizerId) .organizerId(this.organizerId)
.participants(parseParticipants(this.participants)) .participants(this.participants != null
? this.participants.stream()
.map(MeetingParticipantEntity::getUserId)
.collect(Collectors.toList())
: List.of())
.templateId(this.templateId) .templateId(this.templateId)
.build(); .build();
} }
@ -75,13 +95,15 @@ public class MeetingEntity extends BaseTimeEntity {
return MeetingEntity.builder() return MeetingEntity.builder()
.meetingId(meeting.getMeetingId()) .meetingId(meeting.getMeetingId())
.title(meeting.getTitle()) .title(meeting.getTitle())
.purpose(meeting.getPurpose())
.description(meeting.getDescription()) .description(meeting.getDescription())
.scheduledAt(meeting.getScheduledAt()) .scheduledAt(meeting.getScheduledAt())
.endTime(meeting.getEndTime())
.location(meeting.getLocation())
.startedAt(meeting.getStartedAt()) .startedAt(meeting.getStartedAt())
.endedAt(meeting.getEndedAt()) .endedAt(meeting.getEndedAt())
.status(meeting.getStatus()) .status(meeting.getStatus())
.organizerId(meeting.getOrganizerId()) .organizerId(meeting.getOrganizerId())
.participants(formatParticipants(meeting.getParticipants()))
.templateId(meeting.getTemplateId()) .templateId(meeting.getTemplateId())
.build(); .build();
} }
@ -95,18 +117,4 @@ public class MeetingEntity extends BaseTimeEntity {
this.status = "COMPLETED"; this.status = "COMPLETED";
this.endedAt = LocalDateTime.now(); this.endedAt = LocalDateTime.now();
} }
private static List<String> parseParticipants(String participants) {
if (participants == null || participants.isEmpty()) {
return List.of();
}
return Arrays.asList(participants.split(","));
}
private static String formatParticipants(List<String> participants) {
if (participants == null || participants.isEmpty()) {
return "";
}
return String.join(",", participants);
}
} }

Some files were not shown because too many files have changed in this diff Show More