mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 16:06:23 +00:00
Merge branch 'main' of https://github.com/hwanny1128/HGZero into feat/meeting
This commit is contained in:
commit
a7ce5a6edd
7
.gitignore
vendored
7
.gitignore
vendored
@ -43,6 +43,9 @@ examples/
|
||||
.claude/settings.local.json
|
||||
|
||||
# Backup files
|
||||
design/*/*backup.md
|
||||
design/*backup.md
|
||||
design/*/*/*/*back*
|
||||
design/*/*/*back*
|
||||
design/*/*back*
|
||||
design/*back*
|
||||
backup/
|
||||
.vscode/settings.json
|
||||
|
||||
File diff suppressed because one or more lines are too long
88
claude/uiux-v1.4.20-update-summary.md
Normal file
88
claude/uiux-v1.4.20-update-summary.md
Normal 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: 실시간 협업 및 충돌 해결
|
||||
217
claude/userstory-comparison-summary.md
Normal file
217
claude/userstory-comparison-summary.md
Normal 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`
|
||||
343
claude/userstory-comparison-v2.2.0-to-v2.3.0.json
Normal file
343
claude/userstory-comparison-v2.2.0-to-v2.3.0.json
Normal 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 및 시퀀스 설계",
|
||||
"알림 아키텍처 폴링 방식 반영하여 물리 아키텍처 업데이트",
|
||||
"각 유저스토리의 예외처리 시나리오를 테스트 케이스로 전환",
|
||||
"관련 유저스토리 섹션을 활용하여 통합 테스트 시나리오 작성"
|
||||
]
|
||||
}
|
||||
404
claude/userstory-comparison-v2.2.0-to-v2.3.0.md
Normal file
404
claude/userstory-comparison-v2.2.0-to-v2.3.0.md
Normal 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`
|
||||
|
||||
---
|
||||
|
||||
**분석 완료** ✅
|
||||
53
claude/userstory-msc-analysis.md
Normal file
53
claude/userstory-msc-analysis.md
Normal 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
220
claude/userstory-review.md
Normal 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
154
claude/userstory-writing.md
Normal 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
25
claude/v220_codes.txt
Normal 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
28
claude/v230_codes.txt
Normal 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 시스템으로서 | 나는, 사용자에게 중요한 이벤트를 알리기 위해 | 주기적으로 알림 대상을 확인하여 이메일을 발송하고 싶다.
|
||||
454
claude/유저스토리_변경사항_보고서_v2.2.0에서_v2.3.0.md
Normal file
454
claude/유저스토리_변경사항_보고서_v2.2.0에서_v2.3.0.md
Normal 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
|
||||
762
claudedocs/회의진행-개선안-종합보고서.md
Normal file
762
claudedocs/회의진행-개선안-종합보고서.md
Normal 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
|
||||
|
||||
---
|
||||
|
||||
**보고서 종료**
|
||||
@ -13,6 +13,32 @@
|
||||
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) {
|
||||
.main-content {
|
||||
@ -41,18 +67,16 @@
|
||||
<p class="text-muted">회의 유형에 맞는 템플릿을 선택하세요</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Cards -->
|
||||
<div class="template-list" style="display: flex; flex-direction: column; gap: var(--space-md);">
|
||||
<!-- Template Cards (2x2 grid) -->
|
||||
<div class="template-list">
|
||||
<!-- 일반 회의 템플릿 -->
|
||||
<div class="card" style="cursor: pointer;" onclick="selectTemplate('general')">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||
<div style="font-size: 32px;">📋</div>
|
||||
<div>
|
||||
<h3 class="card-title">일반 회의</h3>
|
||||
<p class="text-muted text-small">기본 회의록 형식</p>
|
||||
</div>
|
||||
<div class="card-header" style="display: block !important;">
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="font-size: 20px;">📋</span>
|
||||
<span style="font-size: 14px; font-weight: 600; margin-left: 4px;">일반 회의</span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted);">기본 회의록 형식</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-small text-muted">
|
||||
@ -63,21 +87,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<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')">선택</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="selectTemplate('general')" style="width: 100%;">선택</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 스크럼 회의 템플릿 -->
|
||||
<div class="card" style="cursor: pointer;" onclick="selectTemplate('scrum')">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||
<div style="font-size: 32px;">🏃</div>
|
||||
<div>
|
||||
<h3 class="card-title">스크럼 회의</h3>
|
||||
<p class="text-muted text-small">데일리 스탠드업 형식</p>
|
||||
</div>
|
||||
<div class="card-header" style="display: block !important;">
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="font-size: 20px;">🏃</span>
|
||||
<span style="font-size: 14px; font-weight: 600; margin-left: 4px;">스크럼 회의</span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted);">데일리 스탠드업 형식</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-small text-muted">
|
||||
@ -87,21 +108,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<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')">선택</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="selectTemplate('scrum')" style="width: 100%;">선택</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 킥오프 회의 템플릿 -->
|
||||
<div class="card" style="cursor: pointer;" onclick="selectTemplate('kickoff')">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||
<div style="font-size: 32px;">🚀</div>
|
||||
<div>
|
||||
<h3 class="card-title">킥오프 회의</h3>
|
||||
<p class="text-muted text-small">프로젝트 시작 회의</p>
|
||||
</div>
|
||||
<div class="card-header" style="display: block !important;">
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="font-size: 20px;">🚀</span>
|
||||
<span style="font-size: 14px; font-weight: 600; margin-left: 4px;">킥오프 회의</span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted);">프로젝트 시작 회의</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-small text-muted">
|
||||
@ -112,21 +130,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<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')">선택</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="selectTemplate('kickoff')" style="width: 100%;">선택</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 주간 회의 템플릿 -->
|
||||
<div class="card" style="cursor: pointer;" onclick="selectTemplate('weekly')">
|
||||
<div class="card-header">
|
||||
<div style="display: flex; align-items: center; gap: var(--space-md);">
|
||||
<div style="font-size: 32px;">📅</div>
|
||||
<div>
|
||||
<h3 class="card-title">주간 회의</h3>
|
||||
<p class="text-muted text-small">주간 리뷰 및 계획</p>
|
||||
</div>
|
||||
<div class="card-header" style="display: block !important;">
|
||||
<div style="margin-bottom: 4px;">
|
||||
<span style="font-size: 20px;">📅</span>
|
||||
<span style="font-size: 14px; font-weight: 600; margin-left: 4px;">주간 회의</span>
|
||||
</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted);">주간 리뷰 및 계획</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-small text-muted">
|
||||
@ -137,72 +152,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<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')">선택</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="selectTemplate('weekly')" style="width: 100%;">선택</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
@ -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) {
|
||||
selectedTemplate = templateId;
|
||||
customSections = [...templates[templateId].sections];
|
||||
const templateSections = [...templates[templateId].sections];
|
||||
|
||||
// 회의 진행 화면으로 이동
|
||||
saveToStorage('selectedTemplate', templateId);
|
||||
saveToStorage('templateSections', customSections);
|
||||
saveToStorage('templateSections', templateSections);
|
||||
navigateTo('05-회의진행.html');
|
||||
}
|
||||
|
||||
@ -276,114 +209,6 @@
|
||||
$('#skip-btn')?.addEventListener('click', () => {
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -94,6 +94,11 @@
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.recording-indicator.paused .recording-dot,
|
||||
.recording-indicator.paused .waveform-bar {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.recording-time {
|
||||
font-size: var(--font-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
@ -129,18 +134,23 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
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) {
|
||||
.main-content {
|
||||
padding: 0 var(--space-lg) 88px;
|
||||
padding: var(--space-lg);
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.main-content {
|
||||
padding: 0 var(--space-xl) 88px;
|
||||
padding: var(--space-xl);
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -151,6 +161,8 @@
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.meeting-info-grid {
|
||||
@ -192,6 +204,11 @@
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: var(--space-md);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tabs-header {
|
||||
@ -248,22 +265,19 @@
|
||||
.tab-content {
|
||||
display: none;
|
||||
padding: var(--space-md);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.tab-content {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.tab-content {
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
/* 모든 탭 콘텐츠 내부 요소 텍스트 줄바꿈 강제 */
|
||||
.tab-content * {
|
||||
max-width: 100%;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* 참석자 탭 */
|
||||
@ -437,6 +451,26 @@
|
||||
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 {
|
||||
background: #FAFAFA;
|
||||
@ -564,6 +598,11 @@
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.rec-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.end-meeting-btn {
|
||||
flex: 1;
|
||||
font-size: var(--font-body);
|
||||
@ -580,7 +619,7 @@
|
||||
</div>
|
||||
|
||||
<div class="recording-status-bar">
|
||||
<div class="recording-indicator">
|
||||
<div class="recording-indicator" id="recordingIndicator">
|
||||
<span class="recording-dot"></span>
|
||||
<span class="recording-time" id="recordingTime">00:15:51</span>
|
||||
<div class="waveform">
|
||||
@ -708,7 +747,7 @@
|
||||
</div>
|
||||
|
||||
<h4 class="ai-suggestion-list-title">
|
||||
💬 AI가 실시간으로 분석한 제안사항
|
||||
💬 AI가 실시간으로 분석한 주요 내용
|
||||
</h4>
|
||||
|
||||
<div id="aiSuggestionList">
|
||||
@ -757,8 +796,21 @@
|
||||
<div class="tab-content" id="tab-terms">
|
||||
<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 class="term-item" onclick="showTermDetail('MVP')">
|
||||
<div class="term-item highlight" onclick="showTermDetail('MVP')">
|
||||
<div class="term-name">
|
||||
MVP
|
||||
<span class="term-badge">기획</span>
|
||||
@ -769,7 +821,7 @@
|
||||
<div class="term-context">신제품 기획 회의에서 언급</div>
|
||||
</div>
|
||||
|
||||
<div class="term-item" onclick="showTermDetail('B2C')">
|
||||
<div class="term-item highlight" onclick="showTermDetail('B2C')">
|
||||
<div class="term-name">
|
||||
B2C
|
||||
<span class="term-badge">비즈니스</span>
|
||||
@ -780,7 +832,7 @@
|
||||
<div class="term-context">타겟 고객 분석 시 사용</div>
|
||||
</div>
|
||||
|
||||
<div class="term-item" onclick="showTermDetail('PMF')">
|
||||
<div class="term-item highlight" onclick="showTermDetail('PMF')">
|
||||
<div class="term-name">
|
||||
PMF
|
||||
<span class="term-badge">전략</span>
|
||||
@ -791,7 +843,7 @@
|
||||
<div class="term-context">제품 전략 논의 중 언급</div>
|
||||
</div>
|
||||
|
||||
<div class="term-item" onclick="showTermDetail('CAC')">
|
||||
<div class="term-item highlight" onclick="showTermDetail('CAC')">
|
||||
<div class="term-name">
|
||||
CAC
|
||||
<span class="term-badge">마케팅</span>
|
||||
@ -802,6 +854,102 @@
|
||||
<div class="term-context">마케팅 예산 논의에서 사용</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>
|
||||
|
||||
<!-- 관련 회의록 탭 -->
|
||||
@ -852,11 +1000,12 @@
|
||||
|
||||
<!-- 하단 고정 버튼 영역 -->
|
||||
<div class="bottom-action-bar">
|
||||
<button class="btn btn-ghost pause-btn" onclick="pauseRecording()" id="pauseBtn">
|
||||
<svg class="pause-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<button class="btn btn-ghost pause-btn" onclick="toggleRecording()" id="pauseBtn">
|
||||
<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="14" y="4" width="4" height="16" rx="1"></rect>
|
||||
</svg>
|
||||
<img class="rec-icon" id="recIcon" src="img/rec.png" alt="녹음 재개" style="display: none;">
|
||||
</button>
|
||||
<button class="btn btn-error end-meeting-btn" onclick="endMeeting()">
|
||||
회의 종료
|
||||
@ -979,9 +1128,104 @@
|
||||
// });
|
||||
}
|
||||
|
||||
// 녹음 일시정지
|
||||
function pauseRecording() {
|
||||
alert('녹음이 일시정지되었습니다');
|
||||
// 용어 검색
|
||||
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('녹음이 일시정지되었습니다');
|
||||
} else {
|
||||
// 재개
|
||||
isRecording = true;
|
||||
pauseIcon.style.display = 'block';
|
||||
recIcon.style.display = 'none';
|
||||
recordingIndicator.classList.remove('paused');
|
||||
|
||||
// 타이머 재시작
|
||||
startTimer();
|
||||
|
||||
alert('녹음이 재개되었습니다');
|
||||
}
|
||||
}
|
||||
|
||||
// 회의 종료
|
||||
@ -990,19 +1234,27 @@
|
||||
alert('회의가 종료되었습니다. AI가 회의록을 생성 중입니다...');
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.href = '10-회의록상세조회.html';
|
||||
window.location.href = '07-회의종료.html';
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// 타이머 업데이트
|
||||
function updateTimer() {
|
||||
// 타이머 시작 함수
|
||||
function startTimer() {
|
||||
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++;
|
||||
if (seconds === 60) {
|
||||
seconds = 0;
|
||||
@ -1022,6 +1274,25 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 타이머 초기화 함수
|
||||
function updateTimer() {
|
||||
startTimer();
|
||||
}
|
||||
|
||||
// 관련 회의록 열기
|
||||
function openRelatedDoc(docId) {
|
||||
// 새 탭으로 회의록 상세조회 화면 열기
|
||||
window.open('10-회의록상세조회.html', '_blank');
|
||||
|
||||
// 기본 동작(링크 이동) 방지
|
||||
return false;
|
||||
}
|
||||
|
||||
// 용어 상세 정보 보기
|
||||
function showTermDetail(termName) {
|
||||
alert(`"${termName}" 용어에 대한 상세 정보를 표시합니다.`);
|
||||
}
|
||||
|
||||
// 페이지 로드 시 타이머 시작
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateTimer();
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
## 문서 정보
|
||||
- **작성일**: 2025-10-21
|
||||
- **최종 수정일**: 2025-10-24
|
||||
- **최종 수정일**: 2025-10-25
|
||||
- **작성자**: 이미준 (서비스 기획자)
|
||||
- **버전**: 1.4.18
|
||||
- **버전**: 1.4.20
|
||||
- **설계 철학**: Mobile First Design
|
||||
|
||||
---
|
||||
@ -628,51 +628,30 @@ graph TD
|
||||
|
||||
#### 개요
|
||||
- **목적**: 실시간 회의 진행 및 AI 기반 회의록 자동 작성
|
||||
- **관련 유저스토리**: UFR-MEET-030, UFR-STT-010/020, UFR-AI-010, UFR-AI-040, UFR-COLLAB-010, UFR-RAG-010/020
|
||||
- **관련 유저스토리**: UFR-MEET-030, UFR-STT-010/020, UFR-AI-010, UFR-AI-040, UFR-COLLAB-010, UFR-RAG-010/020, UFR-PART-010/020/030, UFR-HOST-010/020, UFR-TERM-010/020
|
||||
- **비즈니스 중요도**: 높음 (핵심 화면)
|
||||
- **접근 경로**: 템플릿선택 → "이 템플릿으로 시작"
|
||||
- **권한**:
|
||||
- 회의 시작/종료: 회의 생성자 전용
|
||||
- 회의록 편집: 모든 참석자
|
||||
- **접근 경로**: 대시보드 → "참여하기" 버튼 (페이지 전환)
|
||||
- **권한** (MVP 개선):
|
||||
- **회의 생성자 전용**: 회의 종료, 녹음 제어 (일시정지/재개/종료)
|
||||
- **모든 참석자**: 회의 참여, AI 주요 내용 체크, 용어 확인, 관련 회의록 확인, 중도 퇴장
|
||||
|
||||
#### 주요 기능
|
||||
1. 음성 녹음 및 실시간 텍스트 변환 (STT)
|
||||
2. AI 자동 회의록 작성 (구조화)
|
||||
3. **AI 기반 회의 내용 요약 자동 생성** (섹션별)
|
||||
4. 실시간 협업 (여러 참석자 동시 편집)
|
||||
5. 전문용어 자동 감지 및 맥락 기반 설명
|
||||
6. **참고자료 자동 연결** (이전 회의록, 관련 회의록)
|
||||
7. 수동 편집 및 섹션별 작성
|
||||
8. 회의 진행 시간 표시
|
||||
3. **AI 기반 주요 메모 항목 실시간 제안** (UFR-MEET-030)
|
||||
4. 전문용어 자동 감지 및 맥락 기반 설명
|
||||
5. **참고자료 자동 연결** (이전 회의록, 관련 회의록)
|
||||
6. 참석자 관리 및 초대 기능
|
||||
7. 회의 진행 시간 표시
|
||||
|
||||
#### UI 구성요소
|
||||
|
||||
**전체 레이아웃 (2열 구조)**
|
||||
**전체 레이아웃**
|
||||
- **헤더** (Fixed, 상단)
|
||||
- 좌측: "회의 진행 중" 제목 + 경과시간 배지 (빨강, 01:03)
|
||||
- 우측: "회의 종료" 버튼 (민트 그린 테두리)
|
||||
|
||||
- **왼쪽 영역: 회의 내용 작성** (60-70% 너비)
|
||||
- **텍스트 에디터 툴바**
|
||||
- B (Bold), I (Italic), U (Underline)
|
||||
- 색상 선택, 링크 추가
|
||||
|
||||
- **편집 영역** (contentEditable, 스크롤 가능)
|
||||
- 실시간 입력 텍스트: "회의 내용을 작성하거나 AI가 자동으로 작성합니다..."
|
||||
- 섹션 구조:
|
||||
```
|
||||
# 참석자
|
||||
- 김민준
|
||||
- 박서연
|
||||
- 이준호
|
||||
|
||||
# 안건
|
||||
1. 신규 기능 개발 일정 논의
|
||||
2. 예산 편성 검토
|
||||
```
|
||||
- 자동 저장 (30초 간격)
|
||||
|
||||
- **오른쪽 영역: 정보 패널** (30-40% 너비, 탭 구조)
|
||||
- **메인 콘텐츠 영역: 정보 패널** (탭 구조)
|
||||
- **탭 네비게이션** (4개 탭)
|
||||
- 참석자 (3명)
|
||||
- AI 제안
|
||||
@ -709,30 +688,6 @@ graph TD
|
||||
- 본문 폰트: 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: "용어 검색...")
|
||||
@ -805,32 +760,22 @@ graph TD
|
||||
|
||||
- 카드 클릭 시: **새 탭으로 열기** (target="_blank")
|
||||
|
||||
**Mobile (320px~768px)**
|
||||
- **2열 구조를 1열로 전환**
|
||||
- 왼쪽 영역: 메인 콘텐츠 (전체 너비)
|
||||
- 오른쪽 탭 패널: 하단 시트로 표시
|
||||
- 탭 버튼 클릭 시 바텀시트 슬라이드업
|
||||
- 오버레이 + 닫기 버튼
|
||||
**반응형 디자인**
|
||||
- **Mobile (320px~768px)**
|
||||
- 헤더: 고정 상단, 좁은 너비
|
||||
- 메인 콘텐츠: 전체 너비 사용
|
||||
- 탭 콘텐츠: 세로 스크롤
|
||||
- 하단 버튼 영역: 고정 하단
|
||||
|
||||
**Desktop (768px+)**
|
||||
- 2열 고정 레이아웃
|
||||
- 왼쪽: 편집 영역
|
||||
- 오른쪽: 탭 패널 (고정)
|
||||
- **Desktop (768px+)**
|
||||
- 헤더: 고정 상단, 넓은 너비
|
||||
- 메인 콘텐츠: 최대 너비 제한 없이 반응형
|
||||
- 탭 콘텐츠: 더 넓은 영역 활용
|
||||
- 하단 버튼 영역: 고정 하단
|
||||
|
||||
#### 인터랙션
|
||||
1. **텍스트 편집 (왼쪽 영역)**
|
||||
- **편집 모드**: contentEditable 영역 클릭하여 즉시 편집 시작
|
||||
- **자동 저장**: 편집 중 30초 간격 자동 저장
|
||||
- **툴바 사용**:
|
||||
- B (Bold): 선택된 텍스트를 굵게
|
||||
- I (Italic): 선택된 텍스트를 이탤릭체로
|
||||
- U (Underline): 선택된 텍스트에 밑줄
|
||||
- 색상 선택: 텍스트 강조 색상 변경
|
||||
- 링크 추가: URL 입력 모달 표시
|
||||
- **실시간 동기화**: WebSocket 통해 모든 참석자에게 편집 내용 동기화
|
||||
- **충돌 감지**: 동시 편집 시 충돌 감지 및 병합 옵션 제공
|
||||
|
||||
2. **탭 전환 (오른쪽 영역)**
|
||||
1. **탭 전환**
|
||||
- **참석자 탭**: 현재 회의 참석자 목록 표시 (4명) 및 참석자 추가 기능
|
||||
- **참석자 추가 폼** (상단):
|
||||
- 이메일 입력 필드 (form-control 스타일, placeholder: "이메일 주소 입력")
|
||||
@ -847,59 +792,28 @@ graph TD
|
||||
- 상태 표시 없음 (발언 중/온라인 등 제거)
|
||||
- 참석자 수 동적 업데이트 (초대 성공 시)
|
||||
|
||||
- **AI 제안 탭**: AI가 생성한 회의록 개선 제안
|
||||
|
||||
- **실시간 주요 메모 추천** (UFR-MEET-030):
|
||||
- 음성→텍스트 변환 후 AI가 실시간 분석
|
||||
- **중요한 내용으로 판단된 경우에만** 주요 메모 항목 추천
|
||||
- **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. 입력창이 비어있으면 전체 목록 표시
|
||||
- 용어 카드: 용어명 + 카테고리 배지 + 간단한 정의
|
||||
- 카드 클릭 → 확장하여 상세 설명 표시
|
||||
- 상세 설명: 이 회의에서의 의미, 관련 회의록 링크
|
||||
|
||||
@ -908,41 +822,42 @@ graph TD
|
||||
- **녹음 중인 페이지 이탈 방지**: 모든 링크는 새 탭으로 열림
|
||||
- 관련도 표시: 퍼센트 또는 별점으로 시각화
|
||||
|
||||
3. **회의 종료**
|
||||
2. **회의 종료**
|
||||
- 헤더의 "회의 종료" 버튼 클릭
|
||||
- 확인 다이얼로그 표시: "회의를 종료하시겠습니까?"
|
||||
- 확인 → 회의 종료 처리 및 07-회의종료.html로 이동
|
||||
|
||||
4. **실시간 업데이트**
|
||||
3. **실시간 업데이트**
|
||||
- STT 음성 인식 결과 실시간 반영 (3-5초 주기)
|
||||
- 모든 참석자의 편집 내용 실시간 동기화
|
||||
- 수정 사항 하이라이트 표시 (3초간)
|
||||
- AI 제안 실시간 업데이트
|
||||
- 용어 사전 자동 업데이트 (새로운 전문용어 감지 시)
|
||||
- 관련 회의록 목록 동적 갱신
|
||||
|
||||
#### 데이터 요구사항
|
||||
- **입력**:
|
||||
- 회의 ID
|
||||
- 오디오 스트림 (실시간 STT용)
|
||||
- 사용자 편집 내용 (텍스트 입력)
|
||||
- 참석자 초대 이메일
|
||||
- **출력**:
|
||||
- 실시간 텍스트 변환 결과 (STT)
|
||||
- 편집된 회의록 내용
|
||||
- **AI 제안 목록** (회의록 개선 제안)
|
||||
- **AI 제안 목록** (주요 메모 항목 제안)
|
||||
- **전문용어 및 설명** (용어 사전)
|
||||
- **관련 회의록 목록** (32건, 관련도 포함)
|
||||
- 참석자 목록 및 상태
|
||||
- 참석자 목록
|
||||
- **연동**:
|
||||
- STT 서비스 (UFR-AI-010)
|
||||
- AI 서비스 (AI 제안 생성, UFR-AI-040)
|
||||
- RAG 서비스 (관련 회의록 검색)
|
||||
- AI 서비스 (주요 메모 제안 생성, UFR-AI-040)
|
||||
- RAG 서비스 (관련 회의록 검색, 전문용어 자동 감지)
|
||||
- Collaboration 서비스 (실시간 동기화)
|
||||
|
||||
#### 에러 처리
|
||||
- **마이크 권한 거부**: "마이크 권한이 필요합니다" 토스트 + 설정 안내 링크
|
||||
- **STT 실패**: "음성 인식에 실패했습니다. 수동으로 입력해주세요" 토스트
|
||||
- **AI 제안 생성 실패**: "AI 제안을 불러올 수 없습니다" 토스트 (편집은 계속 가능)
|
||||
- **STT 실패**: "음성 인식에 실패했습니다" 토스트 + 재시도 안내
|
||||
- **AI 제안 생성 실패**: "AI 제안을 불러올 수 없습니다" 토스트
|
||||
- **용어 사전 로드 실패**: "용어 사전을 불러올 수 없습니다" 메시지 표시
|
||||
- **관련 자료 검색 실패**: "관련 회의록을 찾을 수 없습니다" 메시지 표시
|
||||
- **동기화 실패**: "네트워크 연결을 확인해주세요. 내용은 로컬에 저장됩니다" 토스트
|
||||
- **편집 충돌**: "다른 참석자가 동일한 부분을 수정 중입니다" 다이얼로그 + 병합 옵션
|
||||
- **참석자 초대 실패**: "초대 링크 전송에 실패했습니다" 토스트 + 재시도 버튼
|
||||
- **동기화 실패**: "네트워크 연결을 확인해주세요" 토스트
|
||||
- **회의 종료 실패**: "회의 종료 중 오류가 발생했습니다" 토스트 + 재시도 버튼
|
||||
|
||||
---
|
||||
@ -1040,15 +955,21 @@ graph TD
|
||||
- 11-회의록수정.html로 이동
|
||||
- URL 파라미터: meetingId
|
||||
- 회의록 상태: 작성중
|
||||
- **옵션 2: 바로 최종 확정**
|
||||
- 확인 다이얼로그 표시
|
||||
- **옵션 2: 바로 최종 확정** (UFR-MEET-050 시나리오 2)
|
||||
- 확인 다이얼로그 표시: "바로 최종 확정하시겠습니까? AI가 정리한 내용 그대로 확정됩니다."
|
||||
- 확인 시:
|
||||
- 모든 안건 검증률 100% 자동 설정
|
||||
- 회의록 상태: 확정완료
|
||||
- 안건별 검증완료 처리
|
||||
- 회의록 상태: "작성중" → "확정완료"로 변경
|
||||
- 확정 시간 기록
|
||||
- 참석자에게 확정 알림 발송
|
||||
- 성공 토스트: "회의록이 최종 확정되었습니다"
|
||||
- 02-대시보드.html로 이동
|
||||
- 10-회의록상세조회.html로 이동
|
||||
- **시나리오 2 특징 (바로 확정)**:
|
||||
- 회의록 수정 단계를 건너뜀
|
||||
- AI 생성 내용을 그대로 확정
|
||||
- 모든 안건이 자동으로 검증완료 처리됨
|
||||
- 확정 후에도 회의 생성자는 수정 가능 (잠금 해제 필요)
|
||||
- **옵션 3: 대시보드로 이동**
|
||||
- 회의록 상태: 작성중
|
||||
- 02-대시보드.html로 이동
|
||||
@ -1474,6 +1395,9 @@ graph TD
|
||||
- **안건 헤더**
|
||||
- 안건 제목 (H4, Bold)
|
||||
- 검증 상태 배지 (검증완료/미검증)
|
||||
- 편집 중 표시 (동시 편집 시)
|
||||
- 다른 사용자 아바타 + 이름
|
||||
- 예: "김민준님 편집 중" (아이콘 + 텍스트)
|
||||
- **AI 한줄 요약** (편집 불가, UFR-AI-036) - 신규
|
||||
- 🔒 아이콘 + 30자 이내 한줄 요약
|
||||
- 읽기 전용 (회색 배경, 민트 그린 좌측 액센트 라인)
|
||||
@ -1605,6 +1529,38 @@ graph TD
|
||||
- 확정완료 회의록 수정 시: 자동으로 "작성중" 상태로 변경
|
||||
- 모든 안건 검증 완료 시: "확정완료"로 변경 제안
|
||||
|
||||
|
||||
9. **안건 기반 충돌 해결 (UFR-COLLAB-020)**
|
||||
- **안건 기반 충돌 방지 메커니즘**:
|
||||
- **다른 안건 동시 편집**: 충돌 없음
|
||||
- 참석자 A가 안건 1 편집 중
|
||||
- 참석자 B가 안건 2 편집 가능
|
||||
- 양쪽 모두 정상 저장 및 동기화
|
||||
|
||||
- **동일 안건 내 다른 필드 편집**: 자동 병합
|
||||
- 참석자 A가 안건 1의 "상세 요약" 편집
|
||||
- 참석자 B가 안건 1의 "관련회의록" 편집
|
||||
- 양쪽 변경 사항 자동 병합
|
||||
|
||||
- **동일 필드 동시 수정**: Last Write Wins
|
||||
- 마지막에 저장된 변경 사항이 적용
|
||||
- 덮어쓰기 경고: "다른 사용자가 이미 수정했습니다. 최신 내용을 확인하세요"
|
||||
- 선택 옵션: 최신 내용 확인 / 내 변경 사항 유지
|
||||
|
||||
- **편집 중 표시**:
|
||||
- 다른 사용자가 편집 중인 안건 표시
|
||||
- 편집자 아바타 + 이름 실시간 표시
|
||||
- 예: "김민준님이 이 안건을 편집 중입니다" + 아바타
|
||||
- 편집 시작 시 해당 안건에 브로드캐스트
|
||||
- 편집 종료 시 표시 제거
|
||||
|
||||
- **충돌 경고 모달**:
|
||||
- 제목: "동시 수정 감지"
|
||||
- 메시지: "다른 사용자가 이미 이 내용을 수정했습니다"
|
||||
- 옵션 버튼:
|
||||
- "최신 내용 보기" (Primary): 다른 사용자 변경사항 로드
|
||||
- "내 변경사항 유지" (Secondary): 현재 내용 유지 (덮어쓰기)
|
||||
|
||||
#### 데이터 요구사항
|
||||
- **입력**:
|
||||
- 회의록 ID (조회)
|
||||
@ -1627,7 +1583,11 @@ graph TD
|
||||
- **자동 저장 실패**: "네트워크 연결을 확인해주세요. 로컬에 임시 저장됩니다"
|
||||
- **AI 요약 재생성 실패**: "요약 생성에 실패했습니다. 수동으로 작성해주세요"
|
||||
- **참고자료 검색 실패**: "회의록을 검색할 수 없습니다"
|
||||
- **충돌 발생**: "다른 참석자가 동일한 부분을 수정했습니다" + 병합 옵션
|
||||
- **충돌 발생**:
|
||||
- 안건 기반 충돌 방지로 최소화
|
||||
- 동일 필드 동시 수정 시: "다른 사용자가 이미 수정했습니다" 경고 모달
|
||||
- 선택 옵션: 최신 내용 확인 / 내 변경사항 유지
|
||||
- 병합 실패 시: "병합 중 오류가 발생했습니다" 에러 메시지
|
||||
- **삭제 실패**: "회의록 삭제에 실패했습니다"
|
||||
|
||||
---
|
||||
@ -2108,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.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) |
|
||||
@ -2138,8 +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.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.17 | 2025-10-24 | 강지수 | 10-회의록상세조회 화면 용어 통일 (섹션 → 안건)<br>- **용어 변경 (요구사항설계검토-report-V1.2.md 반영)**:<br> - 모든 "섹션별" → "안건별"로 용어 통일<br> - 주요 기능, UI 구성요소, 인터랙션, 데이터 요구사항, 에러 처리 섹션 전체 업데이트<br>- **CSS 클래스명 변경 (공통 스타일 + 프로토타입)**:<br> - common.css: `.section` → `.agenda`, `.section-header` → `.agenda-header`, `.section-title` → `.agenda-title`, `.section-content` → `.agenda-content`<br> - 10-회의록상세조회.html: 모든 section 클래스를 agenda 클래스로 일괄 변경<br>- **HTML 주석 업데이트**: "회의록 섹션" → "회의록 안건", "섹션 내용" → "안건 내용"<br>- **일관성 달성**: 유저스토리 v2.1.2의 안건 기반 구조와 완전히 일치 |
|
||||
| 1.4.18 | 2025-10-24 | 강지수 | 12-회의록목록조회 화면 생성자 표시 기능 추가 (유저스토리 v2.1.3 반영)<br>- **목록 표시 정보 추가**: 회의 생성자 표시 (👑 아이콘)<br> - 현재 사용자가 회의 생성자인 경우 회의록 카드 헤더에 👑 아이콘 표시<br> - 위치: 상태 배지와 회의 제목 사이<br> - 스타일: font-size 16px, title="생성자" 툴팁 제공<br>- **UI 구성요소 업데이트**: 회의록 목록 섹션 명세 수정<br> - 좌측 영역에 "생성자 표시" 항목 추가<br> - 검증완료율 표시 조건 명시 (작성중 상태일 때만)<br>- **프로토타입 수정**: design/uiux/prototype/12-회의록목록조회.html<br> - createMeetingCard() 함수: crownEmoji 변수를 creatorBadge로 변경 및 .creator-badge 클래스 적용<br> - common.css: .creator-badge 스타일 추가 (inline-flex, 16px, margin-left 4px, cursor help)<br>- **스타일 가이드 업데이트**: design/uiux/style-guide.md v1.2.6<br> - 생성자 배지 섹션 추가 (배지 시스템 내 우선순위 배지 다음)<br> - 사용 예시 및 사용 위치 명시 (12-회의록목록조회, 02-대시보드, 10-회의록상세조회) |
|
||||
|| 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% 반영 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
@ -2155,5 +2118,3 @@ graph TD
|
||||
- 타이포그래피
|
||||
- 컴포넌트 라이브러리
|
||||
- 아이콘 세트
|
||||
| 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 (실시간 주요 메모 추천 명시 부족 개선) |
|
||||
|
||||
2282
design/userstory.md
2282
design/userstory.md
File diff suppressed because it is too large
Load Diff
@ -18,6 +18,9 @@ task printEnv {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Module dependencies
|
||||
implementation project(':notification')
|
||||
|
||||
// WebSocket
|
||||
implementation 'org.springframework.boot:spring-boot-starter-websocket'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-reactor-netty'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
BIN
meeting/logs/meeting-service.log.2025-10-25.0.gz
Normal file
BIN
meeting/logs/meeting-service.log.2025-10-25.0.gz
Normal file
Binary file not shown.
BIN
meeting/logs/meeting-service.log.2025-10-26.0.gz
Normal file
BIN
meeting/logs/meeting-service.log.2025-10-26.0.gz
Normal file
Binary file not shown.
@ -1,11 +1,15 @@
|
||||
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.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -77,6 +81,101 @@ public class Minutes {
|
||||
*/
|
||||
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.version++;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 섹션 잠금
|
||||
*/
|
||||
public void lockAllSections(String userId) {
|
||||
if (this.sections != null) {
|
||||
for (MinutesSection section : this.sections) {
|
||||
if (!section.isLocked()) {
|
||||
section.lock(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +53,8 @@ public class MeetingService implements
|
||||
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;
|
||||
|
||||
/**
|
||||
* 회의 생성
|
||||
@ -104,6 +106,12 @@ public class MeetingService implements
|
||||
// 5. 회의 저장
|
||||
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);
|
||||
@ -525,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());
|
||||
throw new BusinessException(ErrorCode.DUPLICATE_RESOURCE);
|
||||
}
|
||||
|
||||
// 참석자 목록에 추가
|
||||
meeting.addParticipant(command.email());
|
||||
|
||||
// 저장
|
||||
meetingWriter.save(meeting);
|
||||
// 참석자 저장
|
||||
participantWriter.saveParticipant(command.meetingId(), command.email());
|
||||
|
||||
// TODO: 실제 이메일 발송 구현 필요
|
||||
// 이메일 발송 서비스 호출
|
||||
|
||||
@ -2,11 +2,17 @@ package com.unicorn.hgzero.meeting.biz.service;
|
||||
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.common.exception.ErrorCode;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.Meeting;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.Minutes;
|
||||
import com.unicorn.hgzero.meeting.biz.domain.MinutesSection;
|
||||
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
|
||||
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.MinutesSectionReader;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesSectionWriter;
|
||||
import com.unicorn.hgzero.meeting.biz.usecase.out.MinutesWriter;
|
||||
import com.unicorn.hgzero.meeting.infra.cache.CacheService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
@ -34,6 +40,10 @@ public class MinutesService implements
|
||||
|
||||
private final MinutesReader minutesReader;
|
||||
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
|
||||
@Transactional
|
||||
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)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND));
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.ENTITY_NOT_FOUND, "회의록을 찾을 수 없습니다."));
|
||||
|
||||
// 상태 검증
|
||||
if ("FINALIZED".equals(minutes.getStatus())) {
|
||||
throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE);
|
||||
}
|
||||
// 2. 회의 정보 조회
|
||||
Meeting meeting = meetingReader.findById(minutes.getMeetingId())
|
||||
.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);
|
||||
|
||||
// 저장
|
||||
// 7. 회의록 저장
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -2,71 +2,18 @@ package com.unicorn.hgzero.meeting.infra.cache;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
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.lettuce.LettuceConnectionFactory;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.serializer.StringRedisSerializer;
|
||||
|
||||
/**
|
||||
* Redis 캐시 설정
|
||||
*
|
||||
* RedisConnectionFactory와 RedisTemplate은 RedisConfig에서 정의됨
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
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.password:}")
|
||||
private String redisPassword;
|
||||
|
||||
@Value("${spring.data.redis.database:1}")
|
||||
private int database;
|
||||
|
||||
/**
|
||||
* Redis 연결 팩토리
|
||||
*/
|
||||
@Bean
|
||||
public RedisConnectionFactory redisConnectionFactory() {
|
||||
var factory = new LettuceConnectionFactory(redisHost, redisPort);
|
||||
factory.setDatabase(database);
|
||||
|
||||
// 비밀번호가 설정된 경우에만 적용
|
||||
if (redisPassword != null && !redisPassword.isEmpty()) {
|
||||
factory.setPassword(redisPassword);
|
||||
log.info("Redis 연결 설정 - host: {}, port: {}, database: {}, password: ****", redisHost, redisPort, database);
|
||||
} else {
|
||||
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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.unicorn.hgzero.meeting.infra.controller;
|
||||
|
||||
import com.unicorn.hgzero.common.dto.ApiResponse;
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.meeting.biz.dto.MinutesDTO;
|
||||
import com.unicorn.hgzero.meeting.biz.service.MinutesService;
|
||||
import com.unicorn.hgzero.meeting.biz.service.MinutesSectionService;
|
||||
@ -202,8 +203,16 @@ public class MinutesController {
|
||||
// 응답 DTO 생성
|
||||
MinutesDetailResponse response = convertToMinutesDetailResponse(finalizedMinutes);
|
||||
|
||||
// 캐시 무효화
|
||||
cacheService.evictCacheMinutesDetail(minutesId);
|
||||
// 캐시 저장 (TTL: 10분)
|
||||
try {
|
||||
cacheService.cacheMinutesDetail(minutesId, response);
|
||||
log.debug("캐시에 확정된 회의록 저장 완료 - minutesId: {}", minutesId);
|
||||
} catch (Exception cacheEx) {
|
||||
log.warn("회의록 캐시 저장 실패 - minutesId: {}", minutesId, cacheEx);
|
||||
// 캐시 저장 실패는 무시하고 진행
|
||||
}
|
||||
|
||||
// 캐시 무효화 (목록 캐시)
|
||||
cacheService.evictCacheMinutesList(userId);
|
||||
|
||||
// 회의록 확정 이벤트 발행
|
||||
@ -212,6 +221,10 @@ public class MinutesController {
|
||||
log.info("회의록 확정 성공 - minutesId: {}", minutesId);
|
||||
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) {
|
||||
log.error("회의록 확정 실패 - minutesId: {}", minutesId, e);
|
||||
return ResponseEntity.badRequest()
|
||||
|
||||
@ -58,7 +58,7 @@ public class EventHubPublisher implements EventPublisher {
|
||||
|
||||
@Override
|
||||
public void publishNotificationRequest(NotificationRequestEvent event) {
|
||||
publishEvent(event, event.getRecipientId(),
|
||||
publishEvent(event, event.getRecipientEmail(),
|
||||
EventHubConstants.TOPIC_NOTIFICATION,
|
||||
EventHubConstants.EVENT_TYPE_NOTIFICATION_REQUEST);
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ public class NoOpEventPublisher implements EventPublisher {
|
||||
|
||||
@Override
|
||||
public void publishNotificationRequest(NotificationRequestEvent event) {
|
||||
log.debug("[NoOp] Notification request event: {}", event.getRecipientId());
|
||||
log.debug("[NoOp] Notification request event: {}", event.getRecipientEmail());
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@ -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.usecase.out.MeetingReader;
|
||||
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.repository.MeetingJpaRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@ -24,48 +25,72 @@ import java.util.stream.Collectors;
|
||||
public class MeetingGateway implements MeetingReader, MeetingWriter {
|
||||
|
||||
private final MeetingJpaRepository meetingJpaRepository;
|
||||
private final ParticipantReader participantReader;
|
||||
|
||||
@Override
|
||||
public Optional<Meeting> findById(String meetingId) {
|
||||
return meetingJpaRepository.findById(meetingId)
|
||||
.map(MeetingEntity::toDomain);
|
||||
.map(this::enrichWithParticipants);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByOrganizerId(String organizerId) {
|
||||
return meetingJpaRepository.findByOrganizerId(organizerId).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByStatus(String status) {
|
||||
return meetingJpaRepository.findByStatus(status).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByOrganizerIdAndStatus(String organizerId, String status) {
|
||||
return meetingJpaRepository.findByOrganizerIdAndStatus(organizerId, status).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByScheduledTimeBetween(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
return meetingJpaRepository.findByScheduledAtBetween(startTime, endTime).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Meeting> findByTemplateId(String templateId) {
|
||||
return meetingJpaRepository.findByTemplateId(templateId).stream()
|
||||
.map(MeetingEntity::toDomain)
|
||||
.map(this::enrichWithParticipants)
|
||||
.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
|
||||
public Meeting save(Meeting meeting) {
|
||||
MeetingEntity entity = MeetingEntity.fromDomain(meeting);
|
||||
|
||||
@ -66,7 +66,24 @@ public class MinutesGateway implements MinutesReader, MinutesWriter {
|
||||
|
||||
@Override
|
||||
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);
|
||||
return savedEntity.toDomain();
|
||||
}
|
||||
|
||||
@ -67,7 +67,25 @@ public class MinutesSectionGateway implements MinutesSectionReader, MinutesSecti
|
||||
|
||||
@Override
|
||||
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);
|
||||
return savedEntity.toDomain();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@ import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@ -59,12 +59,16 @@ public class MeetingEntity extends BaseTimeEntity {
|
||||
@Column(name = "organizer_id", length = 50, nullable = false)
|
||||
private String organizerId;
|
||||
|
||||
@Column(name = "participants", columnDefinition = "TEXT")
|
||||
private String participants;
|
||||
|
||||
@Column(name = "template_id", length = 50)
|
||||
private String templateId;
|
||||
|
||||
/**
|
||||
* 회의 참석자 목록 (일대다 관계)
|
||||
*/
|
||||
@OneToMany(mappedBy = "meeting", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||
@Builder.Default
|
||||
private List<MeetingParticipantEntity> participants = new ArrayList<>();
|
||||
|
||||
public Meeting toDomain() {
|
||||
return Meeting.builder()
|
||||
.meetingId(this.meetingId)
|
||||
@ -78,7 +82,11 @@ public class MeetingEntity extends BaseTimeEntity {
|
||||
.endedAt(this.endedAt)
|
||||
.status(this.status)
|
||||
.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)
|
||||
.build();
|
||||
}
|
||||
@ -96,7 +104,6 @@ public class MeetingEntity extends BaseTimeEntity {
|
||||
.endedAt(meeting.getEndedAt())
|
||||
.status(meeting.getStatus())
|
||||
.organizerId(meeting.getOrganizerId())
|
||||
.participants(formatParticipants(meeting.getParticipants()))
|
||||
.templateId(meeting.getTemplateId())
|
||||
.build();
|
||||
}
|
||||
@ -110,18 +117,4 @@ public class MeetingEntity extends BaseTimeEntity {
|
||||
this.status = "COMPLETED";
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,78 @@
|
||||
package com.unicorn.hgzero.meeting.infra.gateway.entity;
|
||||
|
||||
import com.unicorn.hgzero.common.entity.BaseTimeEntity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 회의 참석자 Entity
|
||||
* meeting_id와 user_id를 복합키로 사용하여 다대다 관계 표현
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "meeting_participants")
|
||||
@IdClass(MeetingParticipantId.class)
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MeetingParticipantEntity extends BaseTimeEntity {
|
||||
|
||||
/**
|
||||
* 회의 ID (복합키)
|
||||
*/
|
||||
@Id
|
||||
@Column(name = "meeting_id", length = 50)
|
||||
private String meetingId;
|
||||
|
||||
/**
|
||||
* 사용자 ID (이메일) (복합키)
|
||||
*/
|
||||
@Id
|
||||
@Column(name = "user_id", length = 100)
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 초대 상태 (PENDING, ACCEPTED, DECLINED)
|
||||
*/
|
||||
@Column(name = "invitation_status", length = 20)
|
||||
@Builder.Default
|
||||
private String invitationStatus = "PENDING";
|
||||
|
||||
/**
|
||||
* 참석 여부
|
||||
*/
|
||||
@Column(name = "attended")
|
||||
@Builder.Default
|
||||
private Boolean attended = false;
|
||||
|
||||
/**
|
||||
* 회의 엔티티와의 관계
|
||||
*/
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "meeting_id", insertable = false, updatable = false)
|
||||
private MeetingEntity meeting;
|
||||
|
||||
/**
|
||||
* 초대 수락
|
||||
*/
|
||||
public void accept() {
|
||||
this.invitationStatus = "ACCEPTED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 초대 거절
|
||||
*/
|
||||
public void decline() {
|
||||
this.invitationStatus = "DECLINED";
|
||||
}
|
||||
|
||||
/**
|
||||
* 참석 처리
|
||||
*/
|
||||
public void markAsAttended() {
|
||||
this.attended = true;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package com.unicorn.hgzero.meeting.infra.gateway.entity;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 회의 참석자 복합키
|
||||
* meeting_id와 user_id를 복합키로 사용
|
||||
*/
|
||||
@Getter
|
||||
@EqualsAndHashCode
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class MeetingParticipantId implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 회의 ID
|
||||
*/
|
||||
private String meetingId;
|
||||
|
||||
/**
|
||||
* 사용자 ID (이메일)
|
||||
*/
|
||||
private String userId;
|
||||
}
|
||||
@ -76,6 +76,11 @@ public class MinutesEntity extends BaseTimeEntity {
|
||||
.minutesId(minutes.getMinutesId())
|
||||
.meetingId(minutes.getMeetingId())
|
||||
.title(minutes.getTitle())
|
||||
.sections(minutes.getSections() != null
|
||||
? minutes.getSections().stream()
|
||||
.map(MinutesSectionEntity::fromDomain)
|
||||
.collect(Collectors.toList())
|
||||
: new ArrayList<>())
|
||||
.status(minutes.getStatus())
|
||||
.version(minutes.getVersion())
|
||||
.createdBy(minutes.getCreatedBy())
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package com.unicorn.hgzero.meeting.infra.gateway.repository;
|
||||
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantEntity;
|
||||
import com.unicorn.hgzero.meeting.infra.gateway.entity.MeetingParticipantId;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 회의 참석자 JPA Repository
|
||||
*/
|
||||
@Repository
|
||||
public interface MeetingParticipantJpaRepository extends JpaRepository<MeetingParticipantEntity, MeetingParticipantId> {
|
||||
|
||||
/**
|
||||
* 회의 ID로 참석자 목록 조회
|
||||
*/
|
||||
List<MeetingParticipantEntity> findByMeetingId(String meetingId);
|
||||
|
||||
/**
|
||||
* 사용자 ID로 참여 회의 목록 조회
|
||||
*/
|
||||
List<MeetingParticipantEntity> findByUserId(String userId);
|
||||
|
||||
/**
|
||||
* 회의 ID와 초대 상태로 참석자 목록 조회
|
||||
*/
|
||||
List<MeetingParticipantEntity> findByMeetingIdAndInvitationStatus(String meetingId, String invitationStatus);
|
||||
|
||||
/**
|
||||
* 회의 ID로 참석자 전체 삭제
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM MeetingParticipantEntity p WHERE p.meetingId = :meetingId")
|
||||
void deleteByMeetingId(@Param("meetingId") String meetingId);
|
||||
|
||||
/**
|
||||
* 회의 ID와 사용자 ID로 참석자 존재 여부 확인
|
||||
*/
|
||||
boolean existsByMeetingIdAndUserId(String meetingId, String userId);
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
-- 회의 참석자 테이블 생성
|
||||
CREATE TABLE IF NOT EXISTS meeting_participants (
|
||||
meeting_id VARCHAR(50) NOT NULL,
|
||||
user_id VARCHAR(100) NOT NULL,
|
||||
invitation_status VARCHAR(20) DEFAULT 'PENDING',
|
||||
attended BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (meeting_id, user_id),
|
||||
CONSTRAINT fk_meeting_participants_meeting
|
||||
FOREIGN KEY (meeting_id) REFERENCES meetings(meeting_id)
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- 기존 meetings 테이블의 participants 데이터를 meeting_participants 테이블로 마이그레이션
|
||||
INSERT INTO meeting_participants (meeting_id, user_id, invitation_status, attended, created_at, updated_at)
|
||||
SELECT
|
||||
m.meeting_id,
|
||||
TRIM(participant) as user_id,
|
||||
'PENDING' as invitation_status,
|
||||
FALSE as attended,
|
||||
m.created_at,
|
||||
m.updated_at
|
||||
FROM meetings m
|
||||
CROSS JOIN LATERAL unnest(string_to_array(m.participants, ',')) AS participant
|
||||
WHERE m.participants IS NOT NULL AND m.participants != '';
|
||||
|
||||
-- meetings 테이블에서 participants 컬럼 삭제
|
||||
ALTER TABLE meetings DROP COLUMN IF EXISTS participants;
|
||||
|
||||
-- 인덱스 생성
|
||||
CREATE INDEX idx_meeting_participants_user_id ON meeting_participants(user_id);
|
||||
CREATE INDEX idx_meeting_participants_invitation_status ON meeting_participants(invitation_status);
|
||||
CREATE INDEX idx_meeting_participants_meeting_id_status ON meeting_participants(meeting_id, invitation_status);
|
||||
|
||||
-- 코멘트 추가
|
||||
COMMENT ON TABLE meeting_participants IS '회의 참석자 정보';
|
||||
COMMENT ON COLUMN meeting_participants.meeting_id IS '회의 ID';
|
||||
COMMENT ON COLUMN meeting_participants.user_id IS '사용자 ID (이메일)';
|
||||
COMMENT ON COLUMN meeting_participants.invitation_status IS '초대 상태 (PENDING, ACCEPTED, DECLINED)';
|
||||
COMMENT ON COLUMN meeting_participants.attended IS '참석 여부';
|
||||
@ -35,17 +35,18 @@
|
||||
<!-- Mail Configuration -->
|
||||
<entry key="MAIL_HOST" value="smtp.gmail.com" />
|
||||
<entry key="MAIL_PORT" value="587" />
|
||||
<entry key="MAIL_USERNAME" value="" />
|
||||
<entry key="MAIL_PASSWORD" value="" />
|
||||
<entry key="MAIL_USERNAME" value="du0928@gmail.com" />
|
||||
<entry key="MAIL_PASSWORD" value="dwga zzqo ugnp iskv" />
|
||||
|
||||
<!-- Azure EventHub Configuration -->
|
||||
<entry key="AZURE_EVENTHUB_ENABLED" value="true" />
|
||||
<entry key="EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=" />
|
||||
<entry key="EVENTHUB_NAME" value="notification-events" />
|
||||
<entry key="EVENTHUB_NAME" value="hgzero-eventhub-name" />
|
||||
<entry key="AZURE_EVENTHUB_CONSUMER_GROUP" value="$Default" />
|
||||
|
||||
<!-- Azure Storage Configuration -->
|
||||
<entry key="AZURE_STORAGE_CONNECTION_STRING" value="DefaultEndpointsProtocol=https;AccountName=hgzerostorage;AccountKey=xOQGJhDT6sqOGyTohS7K5dMgGNlryuaQSg8dNCJ40sdGpYok5T5Z88M3xVlk39oeFKiQdGYCihqC+AStBsoBPw==;EndpointSuffix=core.windows.net" />
|
||||
<entry key="AZURE_STORAGE_CONTAINER_NAME" value="eventhub-checkpoints" />
|
||||
<entry key="AZURE_STORAGE_CONTAINER_NAME" value="hgzero-checkpoints" />
|
||||
|
||||
<!-- Notification Configuration -->
|
||||
<entry key="NOTIFICATION_FROM_EMAIL" value="noreply@hgzero.com" />
|
||||
|
||||
173
notification/DB_FIX_GUIDE.md
Normal file
173
notification/DB_FIX_GUIDE.md
Normal file
@ -0,0 +1,173 @@
|
||||
# Notification DB 체크 제약 조건 수정 가이드
|
||||
|
||||
## 🚨 문제 상황
|
||||
```
|
||||
ERROR: new row for relation "notifications" violates check constraint "notifications_notification_type_check"
|
||||
```
|
||||
|
||||
Event Hub에서 메시지는 정상적으로 수신되지만, 데이터베이스 저장 시 체크 제약 조건 위반으로 실패
|
||||
|
||||
## ✅ 해결 방법
|
||||
|
||||
### 방법 1: Azure Portal 사용 (권장)
|
||||
|
||||
1. **Azure Portal 접속**
|
||||
- https://portal.azure.com 접속
|
||||
|
||||
2. **PostgreSQL 서버 찾기**
|
||||
- 리소스 검색에서 "4.230.159.143" 또는 "notificationdb" 검색
|
||||
|
||||
3. **Query Editor 열기**
|
||||
- 좌측 메뉴에서 "Query editor (preview)" 선택
|
||||
- 사용자명: `hgzerouser`
|
||||
- 비밀번호: `Hi5Jessica!`
|
||||
- 데이터베이스: `notificationdb`
|
||||
|
||||
4. **SQL 실행**
|
||||
```sql
|
||||
-- 기존 제약 조건 삭제
|
||||
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_notification_type_check;
|
||||
|
||||
-- 새로운 제약 조건 추가
|
||||
ALTER TABLE notifications
|
||||
ADD CONSTRAINT notifications_notification_type_check
|
||||
CHECK (notification_type IN (
|
||||
'MEETING_INVITATION',
|
||||
'TODO_ASSIGNED',
|
||||
'TODO_REMINDER',
|
||||
'MEETING_REMINDER',
|
||||
'MINUTES_UPDATED',
|
||||
'TODO_COMPLETED'
|
||||
));
|
||||
```
|
||||
|
||||
5. **확인**
|
||||
```sql
|
||||
SELECT constraint_name, check_clause
|
||||
FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'notifications_notification_type_check';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 방법 2: DB 관리 도구 사용
|
||||
|
||||
#### DBeaver 사용
|
||||
1. DBeaver 다운로드: https://dbeaver.io/download/
|
||||
2. 새 연결 생성:
|
||||
- Host: `4.230.159.143`
|
||||
- Port: `5432`
|
||||
- Database: `notificationdb`
|
||||
- Username: `hgzerouser`
|
||||
- Password: `Hi5Jessica!`
|
||||
3. SQL Editor에서 위의 SQL 실행
|
||||
|
||||
#### pgAdmin 사용
|
||||
1. pgAdmin 다운로드: https://www.pgadmin.org/download/
|
||||
2. 서버 등록 후 Query Tool에서 SQL 실행
|
||||
|
||||
---
|
||||
|
||||
### 방법 3: psql 직접 설치 (Mac/Linux)
|
||||
|
||||
```bash
|
||||
# Mac (Homebrew)
|
||||
brew install postgresql@15
|
||||
|
||||
# 실행
|
||||
PGPASSWORD='Hi5Jessica!' psql -h 4.230.159.143 -p 5432 -U hgzerouser -d notificationdb -f notification/fix_constraint.sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 방법 4: Python 스크립트 사용
|
||||
|
||||
```bash
|
||||
# psycopg2 설치
|
||||
pip install psycopg2-binary
|
||||
|
||||
# 스크립트 실행
|
||||
python3 notification/fix_db_constraint.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 실행할 SQL
|
||||
|
||||
생성된 파일: `notification/fix_constraint.sql`
|
||||
|
||||
```sql
|
||||
-- 기존 제약 조건 삭제
|
||||
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_notification_type_check;
|
||||
|
||||
-- 새로운 제약 조건 추가 (Java Enum의 모든 값 포함)
|
||||
ALTER TABLE notifications
|
||||
ADD CONSTRAINT notifications_notification_type_check
|
||||
CHECK (notification_type IN (
|
||||
'MEETING_INVITATION', -- 회의 초대
|
||||
'TODO_ASSIGNED', -- Todo 할당
|
||||
'TODO_REMINDER', -- Todo 리마인더
|
||||
'MEETING_REMINDER', -- 회의 리마인더
|
||||
'MINUTES_UPDATED', -- 회의록 수정
|
||||
'TODO_COMPLETED' -- Todo 완료
|
||||
));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 검증 방법
|
||||
|
||||
### 1. 제약 조건 확인
|
||||
```sql
|
||||
SELECT constraint_name, check_clause
|
||||
FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'notifications_notification_type_check';
|
||||
```
|
||||
|
||||
### 2. 서비스 재시작 후 로그 확인
|
||||
```bash
|
||||
# notification 서비스 재시작
|
||||
cd notification
|
||||
./gradlew bootRun
|
||||
|
||||
# 로그 모니터링
|
||||
tail -f logs/notification-service.log | grep -E "ERROR|이벤트 수신|알림 발송"
|
||||
```
|
||||
|
||||
### 3. 에러가 사라졌는지 확인
|
||||
이 에러가 더 이상 나타나지 않아야 합니다:
|
||||
```
|
||||
ERROR: new row for relation "notifications" violates check constraint "notifications_notification_type_check"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📌 참고
|
||||
|
||||
### Java Enum 정의 위치
|
||||
`notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java:220-227`
|
||||
|
||||
```java
|
||||
public enum NotificationType {
|
||||
MEETING_INVITATION, // 회의 초대
|
||||
TODO_ASSIGNED, // Todo 할당
|
||||
TODO_REMINDER, // Todo 리마인더
|
||||
MEETING_REMINDER, // 회의 리마인더
|
||||
MINUTES_UPDATED, // 회의록 수정
|
||||
TODO_COMPLETED // Todo 완료
|
||||
}
|
||||
```
|
||||
|
||||
### 현재 상태
|
||||
- ✅ Event Hub 연결: 정상
|
||||
- ✅ 메시지 수신: 정상
|
||||
- ✅ 이메일 발송: 정상
|
||||
- ❌ DB 저장: 제약 조건 위반으로 실패 ← **해결 필요**
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
- 프로덕션 환경이라면 백업 먼저 수행
|
||||
- 테스트 환경에서 먼저 검증 후 프로덕션 적용
|
||||
- SQL 실행 권한이 필요합니다
|
||||
1
notification/build-output.log
Normal file
1
notification/build-output.log
Normal file
@ -0,0 +1 @@
|
||||
(eval):1: no such file or directory: ./gradlew
|
||||
@ -26,4 +26,7 @@ dependencies {
|
||||
|
||||
// Azure Event Hubs Checkpoint Store
|
||||
implementation "com.azure:azure-messaging-eventhubs-checkpointstore-blob:${azureEventHubsCheckpointVersion}"
|
||||
|
||||
// H2 Database (for development)
|
||||
runtimeOnly 'com.h2database:h2'
|
||||
}
|
||||
|
||||
39
notification/fix_constraint.sql
Normal file
39
notification/fix_constraint.sql
Normal file
@ -0,0 +1,39 @@
|
||||
-- ====================================================================
|
||||
-- Notification DB 체크 제약 조건 수정 스크립트
|
||||
-- ====================================================================
|
||||
-- 목적: notification_type의 체크 제약 조건을 Java Enum과 일치시킴
|
||||
-- 날짜: 2025-10-26
|
||||
-- ====================================================================
|
||||
|
||||
-- 1. 현재 체크 제약 조건 확인
|
||||
SELECT constraint_name, check_clause
|
||||
FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'notifications_notification_type_check';
|
||||
|
||||
-- 2. 기존 체크 제약 조건 삭제
|
||||
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_notification_type_check;
|
||||
|
||||
-- 3. 새로운 체크 제약 조건 추가 (Java Enum의 모든 값 포함)
|
||||
ALTER TABLE notifications
|
||||
ADD CONSTRAINT notifications_notification_type_check
|
||||
CHECK (notification_type IN (
|
||||
'MEETING_INVITATION', -- 회의 초대
|
||||
'TODO_ASSIGNED', -- Todo 할당
|
||||
'TODO_REMINDER', -- Todo 리마인더
|
||||
'MEETING_REMINDER', -- 회의 리마인더
|
||||
'MINUTES_UPDATED', -- 회의록 수정
|
||||
'TODO_COMPLETED' -- Todo 완료
|
||||
));
|
||||
|
||||
-- 4. 업데이트 결과 확인
|
||||
SELECT constraint_name, check_clause
|
||||
FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'notifications_notification_type_check';
|
||||
|
||||
-- 5. 테이블 정보 확인
|
||||
\d+ notifications
|
||||
|
||||
-- ====================================================================
|
||||
-- 참고: Java Enum 위치
|
||||
-- notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java:220-227
|
||||
-- ====================================================================
|
||||
129
notification/fix_db_constraint.py
Executable file
129
notification/fix_db_constraint.py
Executable file
@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Notification DB 체크 제약 조건 수정 스크립트
|
||||
실행 전 psycopg2 설치 필요: pip install psycopg2-binary
|
||||
"""
|
||||
|
||||
import psycopg2
|
||||
import sys
|
||||
|
||||
# 데이터베이스 연결 정보
|
||||
DB_CONFIG = {
|
||||
'host': '4.230.159.143',
|
||||
'port': 5432,
|
||||
'database': 'notificationdb',
|
||||
'user': 'hgzerouser',
|
||||
'password': 'Hi5Jessica!'
|
||||
}
|
||||
|
||||
# 실행할 SQL
|
||||
SQL_DROP_CONSTRAINT = """
|
||||
ALTER TABLE notifications
|
||||
DROP CONSTRAINT IF EXISTS notifications_notification_type_check;
|
||||
"""
|
||||
|
||||
SQL_ADD_CONSTRAINT = """
|
||||
ALTER TABLE notifications
|
||||
ADD CONSTRAINT notifications_notification_type_check
|
||||
CHECK (notification_type IN (
|
||||
'MEETING_INVITATION',
|
||||
'TODO_ASSIGNED',
|
||||
'TODO_REMINDER',
|
||||
'MEETING_REMINDER',
|
||||
'MINUTES_UPDATED',
|
||||
'TODO_COMPLETED'
|
||||
));
|
||||
"""
|
||||
|
||||
SQL_VERIFY = """
|
||||
SELECT constraint_name, check_clause
|
||||
FROM information_schema.check_constraints
|
||||
WHERE constraint_name = 'notifications_notification_type_check';
|
||||
"""
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("Notification DB 체크 제약 조건 수정 시작")
|
||||
print("=" * 70)
|
||||
|
||||
conn = None
|
||||
cursor = None
|
||||
|
||||
try:
|
||||
# 데이터베이스 연결
|
||||
print(f"\n1. 데이터베이스 연결 중...")
|
||||
print(f" Host: {DB_CONFIG['host']}")
|
||||
print(f" Database: {DB_CONFIG['database']}")
|
||||
|
||||
conn = psycopg2.connect(**DB_CONFIG)
|
||||
cursor = conn.cursor()
|
||||
print(" ✅ 연결 성공")
|
||||
|
||||
# 기존 제약 조건 삭제
|
||||
print("\n2. 기존 체크 제약 조건 삭제 중...")
|
||||
cursor.execute(SQL_DROP_CONSTRAINT)
|
||||
print(" ✅ 삭제 완료")
|
||||
|
||||
# 새로운 제약 조건 추가
|
||||
print("\n3. 새로운 체크 제약 조건 추가 중...")
|
||||
cursor.execute(SQL_ADD_CONSTRAINT)
|
||||
print(" ✅ 추가 완료")
|
||||
|
||||
# 변경 사항 커밋
|
||||
conn.commit()
|
||||
print("\n4. 변경 사항 커밋 완료")
|
||||
|
||||
# 결과 확인
|
||||
print("\n5. 제약 조건 확인 중...")
|
||||
cursor.execute(SQL_VERIFY)
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
print(" ✅ 제약 조건이 정상적으로 생성되었습니다")
|
||||
print(f" - Constraint Name: {result[0]}")
|
||||
print(f" - Check Clause: {result[1]}")
|
||||
else:
|
||||
print(" ⚠️ 제약 조건을 찾을 수 없습니다")
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ 모든 작업이 성공적으로 완료되었습니다!")
|
||||
print("=" * 70)
|
||||
print("\n다음 단계:")
|
||||
print("1. notification 서비스를 재시작하세요")
|
||||
print("2. 로그에서 에러가 사라졌는지 확인하세요")
|
||||
print(" tail -f notification/logs/notification-service.log")
|
||||
|
||||
return 0
|
||||
|
||||
except psycopg2.Error as e:
|
||||
print(f"\n❌ 데이터베이스 오류 발생: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 예기치 않은 오류 발생: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return 1
|
||||
|
||||
finally:
|
||||
# 연결 종료
|
||||
if cursor:
|
||||
cursor.close()
|
||||
if conn:
|
||||
conn.close()
|
||||
print("\n6. 데이터베이스 연결 종료")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("❌ psycopg2 모듈이 설치되지 않았습니다.")
|
||||
print("\n다음 명령어로 설치하세요:")
|
||||
print(" pip install psycopg2-binary")
|
||||
print("\n또는 conda를 사용하는 경우:")
|
||||
print(" conda install -c conda-forge psycopg2")
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
BIN
notification/logs/notification-service.log.2025-10-24.0.gz
Normal file
BIN
notification/logs/notification-service.log.2025-10-24.0.gz
Normal file
Binary file not shown.
BIN
notification/logs/notification-service.log.2025-10-25.0.gz
Normal file
BIN
notification/logs/notification-service.log.2025-10-25.0.gz
Normal file
Binary file not shown.
@ -0,0 +1,43 @@
|
||||
package com.unicorn.hgzero.notification.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
/**
|
||||
* 비동기 처리 설정
|
||||
*
|
||||
* 이메일 발송 등 시간이 오래 걸리는 작업을 비동기로 처리
|
||||
*
|
||||
* @author 준호 (Backend Developer)
|
||||
* @version 1.0
|
||||
* @since 2025-10-26
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class AsyncConfig {
|
||||
|
||||
/**
|
||||
* 이메일 발송용 비동기 Executor
|
||||
*
|
||||
* - 코어 스레드: 5개
|
||||
* - 최대 스레드: 10개
|
||||
* - 큐 용량: 100
|
||||
* - 스레드 이름: email-async-
|
||||
*/
|
||||
@Bean(name = "emailTaskExecutor")
|
||||
public Executor emailTaskExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(5);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("email-async-");
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
executor.setAwaitTerminationSeconds(60);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import com.azure.storage.blob.BlobContainerAsyncClient;
|
||||
import com.azure.storage.blob.BlobContainerClientBuilder;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.Configuration;
|
||||
|
||||
@ -19,6 +20,11 @@ import org.springframework.context.annotation.Configuration;
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnProperty(
|
||||
name = "azure.eventhub.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false
|
||||
)
|
||||
public class BlobStorageConfig {
|
||||
|
||||
@Value("${azure.storage.connection-string}")
|
||||
|
||||
@ -7,6 +7,7 @@ import com.azure.messaging.eventhubs.models.EventContext;
|
||||
import com.azure.storage.blob.BlobContainerAsyncClient;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
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.Configuration;
|
||||
|
||||
@ -24,6 +25,11 @@ import java.util.function.Consumer;
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@ConditionalOnProperty(
|
||||
name = "azure.eventhub.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false
|
||||
)
|
||||
public class EventHubConfig {
|
||||
|
||||
@Value("${azure.eventhub.connection-string}")
|
||||
|
||||
@ -218,7 +218,7 @@ public class Notification {
|
||||
* 알림 유형 Enum
|
||||
*/
|
||||
public enum NotificationType {
|
||||
INVITATION, // 회의 초대
|
||||
MEETING_INVITATION, // 회의 초대
|
||||
TODO_ASSIGNED, // Todo 할당
|
||||
TODO_REMINDER, // Todo 리마인더
|
||||
MEETING_REMINDER, // 회의 리마인더
|
||||
|
||||
@ -176,7 +176,7 @@ public class NotificationSetting {
|
||||
*/
|
||||
public boolean isNotificationTypeEnabled(Notification.NotificationType notificationType) {
|
||||
return switch (notificationType) {
|
||||
case INVITATION -> invitationEnabled;
|
||||
case MEETING_INVITATION -> invitationEnabled;
|
||||
case TODO_ASSIGNED -> todoAssignedEnabled;
|
||||
case TODO_REMINDER -> todoReminderEnabled;
|
||||
case MEETING_REMINDER -> meetingReminderEnabled;
|
||||
|
||||
@ -3,10 +3,12 @@ package com.unicorn.hgzero.notification.event;
|
||||
import com.azure.messaging.eventhubs.models.EventContext;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
|
||||
import com.unicorn.hgzero.notification.event.event.NotificationRequestEvent;
|
||||
import com.unicorn.hgzero.notification.event.event.TodoAssignedEvent;
|
||||
import com.unicorn.hgzero.notification.service.NotificationService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@ -26,6 +28,11 @@ import java.util.function.Consumer;
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(
|
||||
name = "azure.eventhub.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false
|
||||
)
|
||||
public class EventHandler implements Consumer<EventContext> {
|
||||
|
||||
private final NotificationService notificationService;
|
||||
@ -46,7 +53,7 @@ public class EventHandler implements Consumer<EventContext> {
|
||||
// 이벤트 속성 추출
|
||||
Map<String, Object> properties = eventData.getProperties();
|
||||
String topic = (String) properties.get("topic");
|
||||
String eventType = (String) properties.get("eventType");
|
||||
String eventType = (String) properties.get("type");
|
||||
|
||||
log.info("이벤트 수신 - Topic: {}, EventType: {}", topic, eventType);
|
||||
|
||||
@ -55,9 +62,13 @@ public class EventHandler implements Consumer<EventContext> {
|
||||
|
||||
// 토픽 및 이벤트 유형에 따라 처리
|
||||
if ("meeting".equals(topic)) {
|
||||
handleMeetingEvent(eventType, eventBody);
|
||||
log.info("이벤트 처리 제외");
|
||||
// handleMeetingEvent(eventType, eventBody);
|
||||
} else if ("todo".equals(topic)) {
|
||||
handleTodoEvent(eventType, eventBody);
|
||||
log.info("이벤트 처리 제외");
|
||||
// handleTodoEvent(eventType, eventBody);
|
||||
} else if ("notification".equals(topic)) {
|
||||
handleNotificationEvent(eventType, eventBody);
|
||||
} else {
|
||||
log.warn("알 수 없는 토픽: {}", topic);
|
||||
}
|
||||
@ -174,4 +185,47 @@ public class EventHandler implements Consumer<EventContext> {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 관련 이벤트 처리
|
||||
*
|
||||
* @param eventType 이벤트 유형
|
||||
* @param eventBody 이벤트 본문 (JSON)
|
||||
*/
|
||||
private void handleNotificationEvent(String eventType, String eventBody) {
|
||||
try {
|
||||
switch (eventType) {
|
||||
case "NOTIFICATION_REQUEST":
|
||||
NotificationRequestEvent notificationEvent = objectMapper.readValue(
|
||||
eventBody,
|
||||
NotificationRequestEvent.class
|
||||
);
|
||||
processNotificationRequestEvent(notificationEvent);
|
||||
break;
|
||||
|
||||
default:
|
||||
log.warn("알 수 없는 알림 이벤트 유형: {}", eventType);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("알림 이벤트 처리 중 오류 발생 - EventType: {}", eventType, e);
|
||||
throw new RuntimeException("알림 이벤트 처리 실패", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 요청 이벤트 처리 (재시도 지원)
|
||||
*
|
||||
* @param event 알림 요청 이벤트
|
||||
*/
|
||||
private void processNotificationRequestEvent(NotificationRequestEvent event) {
|
||||
retryTemplate.execute(context -> {
|
||||
log.info("알림 발송 시작 - Type: {}, EventId: {}",
|
||||
event.getNotificationType(), event.getEventId());
|
||||
|
||||
notificationService.processNotification(event);
|
||||
|
||||
log.info("알림 발송 완료 - Type: {}", event.getNotificationType());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,176 @@
|
||||
package com.unicorn.hgzero.notification.event.event;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 알림 발송 요청 이벤트 DTO
|
||||
*
|
||||
* 범용 알림 발송 이벤트
|
||||
* 다양한 서비스에서 알림 발송 시 사용
|
||||
*
|
||||
* @author 준호 (Backend Developer)
|
||||
* @version 1.0
|
||||
* @since 2025-10-25
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class NotificationRequestEvent {
|
||||
|
||||
/**
|
||||
* 이벤트 고유 ID (중복 발송 방지용)
|
||||
*/
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* 발송 채널 (EMAIL, SMS)
|
||||
*/
|
||||
private String channel;
|
||||
|
||||
/**
|
||||
* 알림 유형 (INVITATION, TODO_ASSIGNED, REMINDER 등)
|
||||
*/
|
||||
private String notificationType;
|
||||
|
||||
/**
|
||||
* 참조 대상 유형 (MEETING, TODO)
|
||||
*/
|
||||
private String referenceType;
|
||||
|
||||
/**
|
||||
* 참조 대상 ID
|
||||
*/
|
||||
private String referenceId;
|
||||
|
||||
/**
|
||||
* 관련 엔티티 유형 (이벤트 메시지의 relatedEntityType 매핑용)
|
||||
*/
|
||||
private String relatedEntityType;
|
||||
|
||||
/**
|
||||
* 관련 엔티티 ID (이벤트 메시지의 relatedEntityId 매핑용)
|
||||
*/
|
||||
private String relatedEntityId;
|
||||
|
||||
/**
|
||||
* 요청자 ID
|
||||
*/
|
||||
private String requestedBy;
|
||||
|
||||
/**
|
||||
* 요청자 이름
|
||||
*/
|
||||
private String requestedByName;
|
||||
|
||||
/**
|
||||
* 우선순위
|
||||
*/
|
||||
private String priority;
|
||||
|
||||
/**
|
||||
* 발신자
|
||||
*/
|
||||
private String sender;
|
||||
|
||||
/**
|
||||
* 제목 (subject)
|
||||
*/
|
||||
private String subject;
|
||||
|
||||
/**
|
||||
* 예약 발송 시간
|
||||
*/
|
||||
private LocalDateTime scheduledTime;
|
||||
|
||||
/**
|
||||
* 이벤트 발생 시간 (배열 형태로 수신)
|
||||
*/
|
||||
private java.util.List<Integer> eventTime;
|
||||
|
||||
/**
|
||||
* 알림 제목
|
||||
*/
|
||||
private String title;
|
||||
|
||||
/**
|
||||
* 알림 메시지
|
||||
*/
|
||||
private String message;
|
||||
|
||||
/**
|
||||
* 수신자 목록 (다중 수신자용)
|
||||
*/
|
||||
private List<RecipientInfo> recipients;
|
||||
|
||||
/**
|
||||
* 수신자 ID (단일 수신자용)
|
||||
*/
|
||||
private String recipientId;
|
||||
|
||||
/**
|
||||
* 수신자 이름 (단일 수신자용)
|
||||
*/
|
||||
private String recipientName;
|
||||
|
||||
/**
|
||||
* 수신자 이메일 (단일 수신자용)
|
||||
*/
|
||||
private String recipientEmail;
|
||||
|
||||
/**
|
||||
* 참여자 (단일 수신자용, recipients와 함께 사용 가능)
|
||||
*/
|
||||
private String participant;
|
||||
|
||||
/**
|
||||
* 템플릿 ID (이메일 템플릿)
|
||||
*/
|
||||
private String templateId;
|
||||
|
||||
/**
|
||||
* 템플릿 데이터 (템플릿 렌더링에 사용)
|
||||
*/
|
||||
private Map<String, Object> templateData;
|
||||
|
||||
/**
|
||||
* 추가 메타데이터 (회의 정보 등)
|
||||
*/
|
||||
private Map<String, Object> metadata;
|
||||
|
||||
/**
|
||||
* 이벤트 발행 일시
|
||||
*/
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
/**
|
||||
* 수신자 정보 DTO
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class RecipientInfo {
|
||||
/**
|
||||
* 사용자 ID
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 사용자 이름
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 사용자 이메일
|
||||
*/
|
||||
private String email;
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.retry.annotation.Backoff;
|
||||
import org.springframework.retry.annotation.Retryable;
|
||||
import org.springframework.stereotype.Service;
|
||||
@ -22,6 +23,11 @@ import org.springframework.stereotype.Service;
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(
|
||||
name = "azure.eventhub.enabled",
|
||||
havingValue = "true",
|
||||
matchIfMissing = false
|
||||
)
|
||||
public class EventProcessorService {
|
||||
|
||||
private final EventProcessorClient eventProcessorClient;
|
||||
|
||||
@ -8,6 +8,7 @@ import org.springframework.mail.javamail.JavaMailSender;
|
||||
import org.springframework.mail.javamail.MimeMessageHelper;
|
||||
import org.springframework.retry.annotation.Backoff;
|
||||
import org.springframework.retry.annotation.Retryable;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
@ -28,22 +29,23 @@ public class EmailClient {
|
||||
private final JavaMailSender mailSender;
|
||||
|
||||
/**
|
||||
* HTML 이메일 발송 (재시도 지원)
|
||||
* HTML 이메일 발송 (비동기 + 재시도 지원)
|
||||
*
|
||||
* 발송 실패 시 최대 3번까지 재시도
|
||||
* Exponential Backoff: 초기 5분, 최대 30분, 배수 2.0
|
||||
* Exponential Backoff: 초기 1초, 최대 10초, 배수 2.0
|
||||
*
|
||||
* @param to 수신자 이메일 주소
|
||||
* @param subject 이메일 제목
|
||||
* @param htmlContent HTML 이메일 본문
|
||||
* @throws MessagingException 이메일 발송 실패 시
|
||||
*/
|
||||
@Async("emailTaskExecutor")
|
||||
@Retryable(
|
||||
retryFor = {MessagingException.class},
|
||||
maxAttempts = 3,
|
||||
backoff = @Backoff(
|
||||
delay = 300000, // 5분
|
||||
maxDelay = 1800000, // 30분
|
||||
delay = 1000, // 1초
|
||||
maxDelay = 10000, // 10초
|
||||
multiplier = 2.0
|
||||
)
|
||||
)
|
||||
@ -57,6 +59,7 @@ public class EmailClient {
|
||||
helper.setTo(to);
|
||||
helper.setSubject(subject);
|
||||
helper.setText(htmlContent, true); // true = HTML 모드
|
||||
// helper.setFrom("du0928@gmail.com");
|
||||
|
||||
mailSender.send(message);
|
||||
|
||||
@ -69,19 +72,20 @@ public class EmailClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 이메일 발송 (재시도 지원)
|
||||
* 텍스트 이메일 발송 (비동기 + 재시도 지원)
|
||||
*
|
||||
* @param to 수신자 이메일 주소
|
||||
* @param subject 이메일 제목
|
||||
* @param textContent 텍스트 이메일 본문
|
||||
* @throws MessagingException 이메일 발송 실패 시
|
||||
*/
|
||||
@Async("emailTaskExecutor")
|
||||
@Retryable(
|
||||
retryFor = {MessagingException.class},
|
||||
maxAttempts = 3,
|
||||
backoff = @Backoff(
|
||||
delay = 300000,
|
||||
maxDelay = 1800000,
|
||||
delay = 1000, // 1초
|
||||
maxDelay = 10000, // 10초
|
||||
multiplier = 2.0
|
||||
)
|
||||
)
|
||||
|
||||
@ -0,0 +1,173 @@
|
||||
package com.unicorn.hgzero.notification.service;
|
||||
|
||||
import jakarta.mail.MessagingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.retry.support.RetryTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 이메일 알림 발송 서비스
|
||||
*
|
||||
* 템플릿 로드, 렌더링, 이메일 발송 통합 처리
|
||||
* 재시도 로직 포함
|
||||
*
|
||||
* @author 준호 (Backend Developer)
|
||||
* @version 1.0
|
||||
* @since 2025-10-25
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class EmailNotifier {
|
||||
|
||||
private final EmailTemplateService templateService;
|
||||
private final EmailClient emailClient;
|
||||
private final RetryTemplate retryTemplate;
|
||||
|
||||
/**
|
||||
* 이메일 발송 (재시도 지원)
|
||||
*
|
||||
* @param recipientEmail 수신자 이메일
|
||||
* @param subject 이메일 제목
|
||||
* @param htmlContent 이메일 HTML 본문
|
||||
* @return 발송 성공 여부
|
||||
*/
|
||||
public boolean sendEmail(String recipientEmail, String subject, String htmlContent) {
|
||||
try {
|
||||
return retryTemplate.execute(context -> {
|
||||
try {
|
||||
log.info("이메일 발송 시도 #{} - Email: {}", context.getRetryCount() + 1, recipientEmail);
|
||||
|
||||
emailClient.sendHtmlEmail(recipientEmail, subject, htmlContent);
|
||||
|
||||
log.info("이메일 발송 성공 - Email: {}", recipientEmail);
|
||||
return true;
|
||||
|
||||
} catch (MessagingException e) {
|
||||
log.error("이메일 발송 실패 (재시도 #{}) - Email: {}",
|
||||
context.getRetryCount() + 1, recipientEmail, e);
|
||||
|
||||
if (context.getRetryCount() >= 2) {
|
||||
log.error("최대 재시도 횟수 초과 - Email: {}", recipientEmail);
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new RuntimeException("이메일 발송 실패", e);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("이메일 발송 최종 실패 - Email: {}", recipientEmail, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 기반 이메일 발송
|
||||
*
|
||||
* 템플릿을 로드하고 데이터를 렌더링하여 이메일 발송
|
||||
*
|
||||
* @param recipientEmail 수신자 이메일
|
||||
* @param subject 이메일 제목
|
||||
* @param templateId 템플릿 ID
|
||||
* @param templateData 템플릿 데이터
|
||||
* @return 발송 성공 여부
|
||||
*/
|
||||
public boolean sendTemplateEmail(
|
||||
String recipientEmail,
|
||||
String subject,
|
||||
String templateId,
|
||||
Map<String, Object> templateData
|
||||
) {
|
||||
try {
|
||||
log.info("템플릿 이메일 발송 시작 - Template: {}, Email: {}", templateId, recipientEmail);
|
||||
|
||||
// 템플릿 로드 및 렌더링
|
||||
String htmlContent = loadAndRenderTemplate(templateId, templateData);
|
||||
|
||||
// 이메일 발송
|
||||
return sendEmail(recipientEmail, subject, htmlContent);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("템플릿 이메일 발송 실패 - Template: {}, Email: {}", templateId, recipientEmail, e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 로드 및 렌더링
|
||||
*
|
||||
* @param templateId 템플릿 ID
|
||||
* @param templateData 템플릿 데이터
|
||||
* @return 렌더링된 HTML 본문
|
||||
*/
|
||||
public String loadAndRenderTemplate(String templateId, Map<String, Object> templateData) {
|
||||
log.info("템플릿 로드 중 - TemplateId: {}", templateId);
|
||||
|
||||
String htmlContent;
|
||||
|
||||
switch (templateId) {
|
||||
case "meeting-invitation":
|
||||
htmlContent = templateService.renderMeetingInvitation(templateData);
|
||||
break;
|
||||
|
||||
case "todo-assigned":
|
||||
htmlContent = templateService.renderTodoAssigned(templateData);
|
||||
break;
|
||||
|
||||
case "meeting-reminder":
|
||||
// 향후 구현
|
||||
log.warn("회의 알림 템플릿은 아직 구현되지 않았습니다");
|
||||
htmlContent = renderDefaultTemplate(templateData);
|
||||
break;
|
||||
|
||||
case "todo-reminder":
|
||||
// 향후 구현
|
||||
log.warn("Todo 알림 템플릿은 아직 구현되지 않았습니다");
|
||||
htmlContent = renderDefaultTemplate(templateData);
|
||||
break;
|
||||
|
||||
case "minutes-updated":
|
||||
// 향후 구현
|
||||
log.warn("회의록 수정 템플릿은 아직 구현되지 않았습니다");
|
||||
htmlContent = renderDefaultTemplate(templateData);
|
||||
break;
|
||||
|
||||
default:
|
||||
log.warn("알 수 없는 템플릿 ID: {} - 기본 템플릿 사용", templateId);
|
||||
htmlContent = renderDefaultTemplate(templateData);
|
||||
break;
|
||||
}
|
||||
|
||||
log.info("템플릿 렌더링 완료 - TemplateId: {}", templateId);
|
||||
return htmlContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 템플릿 렌더링
|
||||
*
|
||||
* @param templateData 템플릿 데이터
|
||||
* @return 렌더링된 HTML 본문
|
||||
*/
|
||||
private String renderDefaultTemplate(Map<String, Object> templateData) {
|
||||
String title = (String) templateData.getOrDefault("title", "알림");
|
||||
String message = (String) templateData.getOrDefault("message", "");
|
||||
|
||||
return String.format("""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>%s</h2>
|
||||
<p>%s</p>
|
||||
</body>
|
||||
</html>
|
||||
""", title, title, message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
package com.unicorn.hgzero.notification.service;
|
||||
|
||||
import com.unicorn.hgzero.notification.domain.Notification;
|
||||
import com.unicorn.hgzero.notification.domain.NotificationSetting;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 알림 채널 라우팅 서비스
|
||||
*
|
||||
* 사용자 설정에 따라 적절한 알림 채널을 결정
|
||||
* 이메일 우선, SMS 백업 전략 사용
|
||||
*
|
||||
* @author 준호 (Backend Developer)
|
||||
* @version 1.0
|
||||
* @since 2025-10-25
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class NotificationRouter {
|
||||
|
||||
private final EmailNotifier emailNotifier;
|
||||
|
||||
/**
|
||||
* 알림 라우팅 및 발송
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param recipientEmail 수신자 이메일
|
||||
* @param recipientName 수신자 이름
|
||||
* @param subject 이메일 제목
|
||||
* @param htmlContent 이메일 HTML 본문
|
||||
* @param preferences 사용자 알림 설정
|
||||
* @param requestedChannel 요청된 채널
|
||||
* @return 발송 성공 여부
|
||||
*/
|
||||
public boolean routeNotification(
|
||||
String eventId,
|
||||
String recipientEmail,
|
||||
String recipientName,
|
||||
String subject,
|
||||
String htmlContent,
|
||||
NotificationSetting preferences,
|
||||
Notification.NotificationChannel requestedChannel
|
||||
) {
|
||||
log.info("알림 라우팅 시작 - Email: {}, Channel: {}", recipientEmail, requestedChannel);
|
||||
|
||||
// 채널 결정
|
||||
Notification.NotificationChannel selectedChannel = determineChannel(preferences, requestedChannel);
|
||||
|
||||
log.info("선택된 채널: {}", selectedChannel);
|
||||
|
||||
// 채널별 발송
|
||||
boolean success = false;
|
||||
|
||||
switch (selectedChannel) {
|
||||
case EMAIL:
|
||||
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
|
||||
break;
|
||||
|
||||
case SMS:
|
||||
// SMS 발송 로직 (향후 구현)
|
||||
log.warn("SMS 채널은 아직 구현되지 않았습니다. 이메일로 대체 발송 시도");
|
||||
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
|
||||
break;
|
||||
|
||||
case PUSH:
|
||||
// PUSH 알림 로직 (향후 구현)
|
||||
log.warn("PUSH 채널은 아직 구현되지 않았습니다. 이메일로 대체 발송 시도");
|
||||
success = emailNotifier.sendEmail(recipientEmail, subject, htmlContent);
|
||||
break;
|
||||
|
||||
default:
|
||||
log.error("알 수 없는 채널: {}", selectedChannel);
|
||||
break;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
log.info("알림 발송 성공 - Email: {}, Channel: {}", recipientEmail, selectedChannel);
|
||||
} else {
|
||||
log.error("알림 발송 실패 - Email: {}, Channel: {}", recipientEmail, selectedChannel);
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널 결정 로직
|
||||
*
|
||||
* 사용자 설정과 요청 채널을 고려하여 최종 채널 결정
|
||||
* 이메일 우선, SMS 백업 전략
|
||||
*
|
||||
* @param preferences 사용자 알림 설정
|
||||
* @param requestedChannel 요청된 채널
|
||||
* @return 선택된 채널
|
||||
*/
|
||||
private Notification.NotificationChannel determineChannel(
|
||||
NotificationSetting preferences,
|
||||
Notification.NotificationChannel requestedChannel
|
||||
) {
|
||||
// 사용자 설정이 없으면 요청된 채널 사용
|
||||
if (preferences == null) {
|
||||
log.info("사용자 설정 없음 - 요청 채널 사용: {}", requestedChannel);
|
||||
return requestedChannel != null ? requestedChannel : Notification.NotificationChannel.EMAIL;
|
||||
}
|
||||
|
||||
// 이메일 우선 전략
|
||||
if (requestedChannel == Notification.NotificationChannel.EMAIL && preferences.isChannelEnabled(Notification.NotificationChannel.EMAIL)) {
|
||||
return Notification.NotificationChannel.EMAIL;
|
||||
}
|
||||
|
||||
// SMS 백업 전략
|
||||
if (requestedChannel == Notification.NotificationChannel.SMS && preferences.isChannelEnabled(Notification.NotificationChannel.SMS)) {
|
||||
return Notification.NotificationChannel.SMS;
|
||||
}
|
||||
|
||||
// PUSH 알림 전략
|
||||
if (requestedChannel == Notification.NotificationChannel.PUSH && preferences.isChannelEnabled(Notification.NotificationChannel.PUSH)) {
|
||||
return Notification.NotificationChannel.PUSH;
|
||||
}
|
||||
|
||||
// 기본값: 이메일
|
||||
log.info("요청된 채널 사용 불가 - 기본 이메일 채널 사용");
|
||||
return Notification.NotificationChannel.EMAIL;
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import com.unicorn.hgzero.notification.domain.Notification;
|
||||
import com.unicorn.hgzero.notification.domain.NotificationRecipient;
|
||||
import com.unicorn.hgzero.notification.domain.NotificationSetting;
|
||||
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
|
||||
import com.unicorn.hgzero.notification.event.event.NotificationRequestEvent;
|
||||
import com.unicorn.hgzero.notification.event.event.TodoAssignedEvent;
|
||||
import com.unicorn.hgzero.notification.repository.NotificationRecipientRepository;
|
||||
import com.unicorn.hgzero.notification.repository.NotificationRepository;
|
||||
@ -41,6 +42,8 @@ public class NotificationService {
|
||||
private final NotificationSettingRepository settingRepository;
|
||||
private final EmailTemplateService templateService;
|
||||
private final EmailClient emailClient;
|
||||
private final NotificationRouter notificationRouter;
|
||||
private final EmailNotifier emailNotifier;
|
||||
|
||||
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
|
||||
|
||||
@ -64,7 +67,7 @@ public class NotificationService {
|
||||
.eventId(event.getEventId())
|
||||
.referenceId(event.getMeetingId())
|
||||
.referenceType(Notification.ReferenceType.MEETING)
|
||||
.notificationType(Notification.NotificationType.INVITATION)
|
||||
.notificationType(Notification.NotificationType.MEETING_INVITATION)
|
||||
.title("회의 초대: " + event.getTitle())
|
||||
.message(event.getDescription())
|
||||
.status(Notification.NotificationStatus.PROCESSING)
|
||||
@ -80,7 +83,7 @@ public class NotificationService {
|
||||
for (MeetingCreatedEvent.ParticipantInfo participant : event.getParticipants()) {
|
||||
try {
|
||||
// 3-1. 알림 설정 확인
|
||||
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.INVITATION)) {
|
||||
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.MEETING_INVITATION)) {
|
||||
log.info("알림 설정에 의해 발송 제외 - UserId: {}", participant.getUserId());
|
||||
continue;
|
||||
}
|
||||
@ -251,6 +254,254 @@ public class NotificationService {
|
||||
log.info("Todo 할당 알림 처리 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 범용 알림 발송 처리
|
||||
*
|
||||
* NotificationRequest 이벤트를 처리하여 알림 발송
|
||||
* 중복 방지, 사용자 설정 확인, 채널 라우팅 포함
|
||||
*
|
||||
* @param event 알림 발송 요청 이벤트
|
||||
*/
|
||||
public void processNotification(NotificationRequestEvent event) {
|
||||
// eventId가 없으면 UUID 생성
|
||||
String eventId = event.getEventId();
|
||||
if (eventId == null || eventId.isBlank()) {
|
||||
eventId = java.util.UUID.randomUUID().toString();
|
||||
log.info("EventId가 없어 생성함 - EventId: {}", eventId);
|
||||
}
|
||||
|
||||
log.info("알림 발송 처리 시작 - EventId: {}, Type: {}",
|
||||
eventId, event.getNotificationType());
|
||||
|
||||
// 1. 중복 발송 방지 체크
|
||||
if (notificationRepository.existsByEventId(eventId)) {
|
||||
log.warn("이미 처리된 이벤트 - EventId: {}", eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 알림 유형 및 참조 유형 변환
|
||||
Notification.NotificationType notificationType = parseNotificationType(event.getNotificationType());
|
||||
|
||||
// referenceType 또는 relatedEntityType 중 하나를 사용
|
||||
String referenceTypeStr = event.getReferenceType() != null
|
||||
? event.getReferenceType()
|
||||
: event.getRelatedEntityType();
|
||||
Notification.ReferenceType referenceType = parseReferenceType(referenceTypeStr);
|
||||
|
||||
// referenceId 또는 relatedEntityId 중 하나를 사용
|
||||
String referenceIdStr = event.getReferenceId() != null
|
||||
? event.getReferenceId()
|
||||
: event.getRelatedEntityId();
|
||||
|
||||
Notification.NotificationChannel channel = parseChannel(event.getChannel());
|
||||
|
||||
// 3. 알림 엔티티 생성
|
||||
Notification notification = Notification.builder()
|
||||
.eventId(eventId)
|
||||
.referenceId(referenceIdStr)
|
||||
.referenceType(referenceType)
|
||||
.notificationType(notificationType)
|
||||
.title(event.getTitle())
|
||||
.message(event.getMessage())
|
||||
.status(Notification.NotificationStatus.PROCESSING)
|
||||
.channel(channel)
|
||||
.build();
|
||||
|
||||
notificationRepository.save(notification);
|
||||
|
||||
// 4. 수신자 리스트 구성 (단일 수신자 또는 다중 수신자)
|
||||
java.util.List<NotificationRequestEvent.RecipientInfo> recipientList = new java.util.ArrayList<>();
|
||||
|
||||
// 4-1. 단일 수신자 필드가 있으면 추가
|
||||
if (event.getRecipientEmail() != null && !event.getRecipientEmail().isBlank()) {
|
||||
NotificationRequestEvent.RecipientInfo singleRecipient = NotificationRequestEvent.RecipientInfo.builder()
|
||||
.userId(event.getRecipientId())
|
||||
.name(event.getRecipientName())
|
||||
.email(event.getRecipientEmail())
|
||||
.build();
|
||||
recipientList.add(singleRecipient);
|
||||
log.info("단일 수신자 처리 - Email: {}", event.getRecipientEmail());
|
||||
}
|
||||
|
||||
// 4-2. recipients 리스트가 있으면 추가
|
||||
if (event.getRecipients() != null && !event.getRecipients().isEmpty()) {
|
||||
recipientList.addAll(event.getRecipients());
|
||||
log.info("다중 수신자 처리 - Count: {}", event.getRecipients().size());
|
||||
}
|
||||
|
||||
// 4-3. 수신자가 없으면 경고 후 종료
|
||||
if (recipientList.isEmpty()) {
|
||||
log.warn("수신자가 없습니다 - EventId: {}", event.getEventId());
|
||||
notification.updateStatus(Notification.NotificationStatus.FAILED);
|
||||
notificationRepository.save(notification);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. 각 수신자에게 알림 발송
|
||||
int successCount = 0;
|
||||
int failureCount = 0;
|
||||
|
||||
for (NotificationRequestEvent.RecipientInfo recipient : recipientList) {
|
||||
try {
|
||||
// 5-1. 사용자 알림 설정 조회
|
||||
String userId = recipient.getUserId();
|
||||
Optional<NotificationSetting> preferences = Optional.empty();
|
||||
|
||||
if (userId != null && !userId.isBlank()) {
|
||||
preferences = settingRepository.findByUserId(userId);
|
||||
|
||||
// 5-2. 알림 설정 확인 (userId가 있는 경우만)
|
||||
if (!canSendNotification(userId, notificationType)) {
|
||||
log.info("알림 설정에 의해 발송 제외 - UserId: {}", userId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 5-3. 수신자 엔티티 생성
|
||||
NotificationRecipient recipientEntity = NotificationRecipient.builder()
|
||||
.recipientUserId(userId)
|
||||
.recipientName(recipient.getName())
|
||||
.recipientEmail(recipient.getEmail())
|
||||
.status(NotificationRecipient.RecipientStatus.PENDING)
|
||||
.build();
|
||||
|
||||
notification.addRecipient(recipientEntity);
|
||||
|
||||
// 5-4. 템플릿 렌더링 (템플릿 ID가 있는 경우)
|
||||
String htmlContent;
|
||||
if (event.getTemplateId() != null && event.getTemplateData() != null) {
|
||||
htmlContent = emailNotifier.loadAndRenderTemplate(
|
||||
event.getTemplateId(),
|
||||
event.getTemplateData()
|
||||
);
|
||||
} else {
|
||||
// 기본 메시지 사용
|
||||
htmlContent = createDefaultHtmlContent(event.getTitle(), event.getMessage());
|
||||
}
|
||||
|
||||
// 5-5. 채널 라우팅 및 발송
|
||||
boolean sent = notificationRouter.routeNotification(
|
||||
eventId,
|
||||
recipient.getEmail(),
|
||||
recipient.getName(),
|
||||
event.getTitle(),
|
||||
htmlContent,
|
||||
preferences.orElse(null),
|
||||
channel
|
||||
);
|
||||
|
||||
// 5-6. 발송 결과 처리
|
||||
if (sent) {
|
||||
recipientEntity.markAsSent();
|
||||
notification.incrementSentCount();
|
||||
successCount++;
|
||||
log.info("알림 발송 성공 - Email: {}", recipient.getEmail());
|
||||
} else {
|
||||
recipientEntity.markAsFailed("발송 실패");
|
||||
notification.incrementFailedCount();
|
||||
failureCount++;
|
||||
log.error("알림 발송 실패 - Email: {}", recipient.getEmail());
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
// 5-7. 예외 처리
|
||||
NotificationRecipient recipientEntity = notification.getRecipients().stream()
|
||||
.filter(r -> recipient.getEmail().equals(r.getRecipientEmail()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (recipientEntity != null) {
|
||||
recipientEntity.markAsFailed(e.getMessage());
|
||||
notification.incrementFailedCount();
|
||||
}
|
||||
|
||||
failureCount++;
|
||||
log.error("알림 발송 중 오류 발생 - Email: {}", recipient.getEmail(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 알림 상태 업데이트
|
||||
if (successCount > 0 && failureCount == 0) {
|
||||
notification.updateStatus(Notification.NotificationStatus.SENT);
|
||||
} else if (successCount > 0 && failureCount > 0) {
|
||||
notification.updateStatus(Notification.NotificationStatus.PARTIAL);
|
||||
} else {
|
||||
notification.updateStatus(Notification.NotificationStatus.FAILED);
|
||||
}
|
||||
|
||||
notificationRepository.save(notification);
|
||||
|
||||
log.info("알림 발송 처리 완료 - 성공: {}, 실패: {}", successCount, failureCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 유형 문자열을 Enum으로 변환
|
||||
*
|
||||
* @param typeString 알림 유형 문자열
|
||||
* @return 알림 유형 Enum
|
||||
*/
|
||||
private Notification.NotificationType parseNotificationType(String typeString) {
|
||||
try {
|
||||
return Notification.NotificationType.valueOf(typeString);
|
||||
} catch (Exception e) {
|
||||
log.warn("알 수 없는 알림 유형: {} - MEETING_INVITATION으로 기본 설정", typeString);
|
||||
return Notification.NotificationType.MEETING_INVITATION;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참조 유형 문자열을 Enum으로 변환
|
||||
*
|
||||
* @param typeString 참조 유형 문자열
|
||||
* @return 참조 유형 Enum
|
||||
*/
|
||||
private Notification.ReferenceType parseReferenceType(String typeString) {
|
||||
try {
|
||||
return Notification.ReferenceType.valueOf(typeString);
|
||||
} catch (Exception e) {
|
||||
log.warn("알 수 없는 참조 유형: {} - MEETING으로 기본 설정", typeString);
|
||||
return Notification.ReferenceType.MEETING;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 채널 문자열을 Enum으로 변환
|
||||
*
|
||||
* @param channelString 채널 문자열
|
||||
* @return 채널 Enum
|
||||
*/
|
||||
private Notification.NotificationChannel parseChannel(String channelString) {
|
||||
try {
|
||||
return Notification.NotificationChannel.valueOf(channelString);
|
||||
} catch (Exception e) {
|
||||
log.warn("알 수 없는 채널: {} - EMAIL로 기본 설정", channelString);
|
||||
return Notification.NotificationChannel.EMAIL;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 HTML 콘텐츠 생성
|
||||
*
|
||||
* @param title 제목
|
||||
* @param message 메시지
|
||||
* @return HTML 본문
|
||||
*/
|
||||
private String createDefaultHtmlContent(String title, String message) {
|
||||
return String.format("""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>%s</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>%s</h2>
|
||||
<p>%s</p>
|
||||
</body>
|
||||
</html>
|
||||
""", title, title, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 발송 가능 여부 확인
|
||||
*
|
||||
@ -265,7 +516,7 @@ public class NotificationService {
|
||||
|
||||
// 설정이 없으면 기본값으로 발송 허용 (이메일, 초대/할당 알림만)
|
||||
if (settingOpt.isEmpty()) {
|
||||
return notificationType == Notification.NotificationType.INVITATION
|
||||
return notificationType == Notification.NotificationType.MEETING_INVITATION
|
||||
|| notificationType == Notification.NotificationType.TODO_ASSIGNED;
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ spring:
|
||||
format_sql: true
|
||||
use_sql_comments: true
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
ddl-auto: ${JPA_DDL_AUTO:none}
|
||||
|
||||
# Redis Configuration
|
||||
data:
|
||||
@ -49,14 +49,15 @@ spring:
|
||||
password: ${MAIL_PASSWORD:}
|
||||
properties:
|
||||
mail:
|
||||
debug: true
|
||||
smtp:
|
||||
auth: true
|
||||
starttls:
|
||||
enable: true
|
||||
required: true
|
||||
connectiontimeout: 5000
|
||||
timeout: 5000
|
||||
writetimeout: 5000
|
||||
connectiontimeout: 3000
|
||||
timeout: 3000
|
||||
writetimeout: 3000
|
||||
|
||||
# Thymeleaf Configuration
|
||||
thymeleaf:
|
||||
@ -83,6 +84,7 @@ cors:
|
||||
# Azure Event Hubs Configuration
|
||||
azure:
|
||||
eventhub:
|
||||
enabled: ${AZURE_EVENTHUB_ENABLED:false}
|
||||
connection-string: ${EVENTHUB_CONNECTION_STRING:}
|
||||
name: ${EVENTHUB_NAME:notification-events}
|
||||
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default}
|
||||
@ -90,16 +92,17 @@ azure:
|
||||
# Azure Blob Storage Configuration (for Event Hub Checkpoint)
|
||||
storage:
|
||||
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
|
||||
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}
|
||||
container-name: ${AZURE_STORAGE_CONTAINER_NAME:hgzero-checkpoints}
|
||||
|
||||
# Notification Configuration
|
||||
notification:
|
||||
from-email: ${NOTIFICATION_FROM_EMAIL:noreply@hgzero.com}
|
||||
from-name: ${NOTIFICATION_FROM_NAME:HGZero}
|
||||
retry:
|
||||
max-attempts: ${NOTIFICATION_RETRY_MAX_ATTEMPTS:3}
|
||||
initial-interval: ${NOTIFICATION_RETRY_INITIAL_INTERVAL:1000}
|
||||
multiplier: ${NOTIFICATION_RETRY_MULTIPLIER:2.0}
|
||||
# retry 설정은 EmailClient.java의 @Retryable 어노테이션에서 직접 관리됨
|
||||
# retry:
|
||||
# max-attempts: 3
|
||||
# initial-interval: 1000 # 1초
|
||||
# multiplier: 2.0
|
||||
|
||||
# Actuator Configuration
|
||||
management:
|
||||
|
||||
@ -3,8 +3,54 @@
|
||||
<ExternalSystemSettings>
|
||||
<option name="env">
|
||||
<map>
|
||||
<!-- Database Configuration -->x
|
||||
<entry key="DB_KIND" value="postgresql" />
|
||||
<entry key="DB_HOST" value="20.214.121.121" />
|
||||
<entry key="DB_PORT" value="5432" />
|
||||
<entry key="DB_NAME" value="userdb" />
|
||||
<entry key="DB_USERNAME" value="hgzerouser" />
|
||||
<entry key="DB_PASSWORD" value="Hi5Jessica!" />
|
||||
<entry key="JWT_SECRET" value="my-super-secret-jwt-key-for-hgzero-meeting-service-2024" />
|
||||
|
||||
<!-- JPA Configuration -->
|
||||
<entry key="SHOW_SQL" value="true" />
|
||||
<entry key="JPA_DDL_AUTO" value="update" />
|
||||
|
||||
<!-- Redis Configuration -->
|
||||
<entry key="REDIS_HOST" value="20.249.177.114" />
|
||||
<entry key="REDIS_PORT" value="6379" />
|
||||
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||
<entry key="REDIS_DATABASE" value="1" />
|
||||
|
||||
<!-- Server Configuration -->
|
||||
<entry key="SERVER_PORT" value="8081" />
|
||||
|
||||
<!-- Redis Configuration -->
|
||||
<entry key="REDIS_HOST" value="20.249.177.114" />
|
||||
<entry key="REDIS_PORT" value="6379" />
|
||||
<entry key="REDIS_PASSWORD" value="Hi5Jessica!" />
|
||||
<entry key="REDIS_DATABASE" value="1" />
|
||||
|
||||
<!-- Spring Profile -->
|
||||
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
|
||||
|
||||
<!-- Azure EventHub Configuration -->
|
||||
<entry key="EVENTHUB_CONNECTION_STRING" value="Endpoint=sb://hgzero-eventhub-ns.servicebus.windows.net/;SharedAccessKeyName=RootManageSharedAccessKey;SharedAccessKey=VUqZ9vFgu35E3c6RiUzoOGVUP8IZpFvlV+AEhC6sUpo=" />
|
||||
<entry key="EVENTHUB_NAME" value="hgzero-eventhub-name" />
|
||||
<entry key="EVENTHUB_CONSUMER_GROUP" value="$Default" />
|
||||
|
||||
<!-- Logging Configuration -->
|
||||
<entry key="LOG_LEVEL_ROOT" value="INFO" />
|
||||
<entry key="LOG_LEVEL_APP" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_WEB" value="INFO" />
|
||||
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_WEBSOCKET" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SQL" value="DEBUG" />
|
||||
<entry key="LOG_LEVEL_SQL_TYPE" value="TRACE" />
|
||||
<entry key="LOG_FILE" value="logs/user-service.log" />
|
||||
<entry key="LOG_MAX_FILE_SIZE" value="10MB" />
|
||||
<entry key="LOG_MAX_HISTORY" value="7" />
|
||||
<entry key="LOG_TOTAL_SIZE_CAP" value="100MB" />
|
||||
</map>
|
||||
</option>
|
||||
<option name="executionName" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
BIN
user/logs/user-service.log.2025-10-24.0.gz
Normal file
BIN
user/logs/user-service.log.2025-10-24.0.gz
Normal file
Binary file not shown.
@ -0,0 +1,91 @@
|
||||
package com.unicorn.hgzero.user.config;
|
||||
|
||||
import io.lettuce.core.ClientOptions;
|
||||
import io.lettuce.core.ReadFrom;
|
||||
import io.lettuce.core.SocketOptions;
|
||||
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 설정
|
||||
* Lettuce 클라이언트를 사용하여 Redis 연결 및 템플릿 구성
|
||||
*/
|
||||
@Configuration
|
||||
public class RedisConfig {
|
||||
|
||||
@Value("${spring.data.redis.host:localhost}")
|
||||
private String host;
|
||||
|
||||
@Value("${spring.data.redis.port:6379}")
|
||||
private int port;
|
||||
|
||||
@Value("${spring.data.redis.password:}")
|
||||
private String password;
|
||||
|
||||
@Value("${spring.data.redis.database:0}")
|
||||
private int database;
|
||||
|
||||
/**
|
||||
* Redis 연결 팩토리 설정
|
||||
* ReadFrom.MASTER로 설정하여 마스터에서만 읽기/쓰기 수행
|
||||
*/
|
||||
@Bean
|
||||
public RedisConnectionFactory redisConnectionFactory() {
|
||||
// Redis Standalone 설정
|
||||
RedisStandaloneConfiguration redisStandaloneConfiguration = new RedisStandaloneConfiguration();
|
||||
redisStandaloneConfiguration.setHostName(host);
|
||||
redisStandaloneConfiguration.setPort(port);
|
||||
redisStandaloneConfiguration.setDatabase(database);
|
||||
|
||||
// 비밀번호가 있는 경우에만 설정
|
||||
if (password != null && !password.isEmpty()) {
|
||||
redisStandaloneConfiguration.setPassword(password);
|
||||
}
|
||||
|
||||
// Lettuce 클라이언트 설정
|
||||
SocketOptions socketOptions = SocketOptions.builder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
ClientOptions clientOptions = ClientOptions.builder()
|
||||
.socketOptions(socketOptions)
|
||||
.build();
|
||||
|
||||
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
|
||||
.clientOptions(clientOptions)
|
||||
.commandTimeout(Duration.ofSeconds(5))
|
||||
.readFrom(ReadFrom.MASTER) // 마스터에서만 읽기/쓰기
|
||||
.build();
|
||||
|
||||
return new LettuceConnectionFactory(redisStandaloneConfiguration, clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* RedisTemplate 설정
|
||||
* String 키와 값을 사용하는 템플릿 구성
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, String> template = new RedisTemplate<>();
|
||||
template.setConnectionFactory(connectionFactory);
|
||||
|
||||
// String 직렬화 설정
|
||||
StringRedisSerializer stringSerializer = new StringRedisSerializer();
|
||||
template.setKeySerializer(stringSerializer);
|
||||
template.setValueSerializer(stringSerializer);
|
||||
template.setHashKeySerializer(stringSerializer);
|
||||
template.setHashValueSerializer(stringSerializer);
|
||||
|
||||
template.afterPropertiesSet();
|
||||
return template;
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHt
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
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.CorsConfigurationSource;
|
||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
@ -69,7 +71,8 @@ public class SecurityConfig {
|
||||
// 허용할 헤더
|
||||
configuration.setAllowedHeaders(Arrays.asList(
|
||||
"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"
|
||||
));
|
||||
|
||||
// 자격 증명 허용
|
||||
@ -82,4 +85,24 @@ public class SecurityConfig {
|
||||
source.registerCorsConfiguration("/**", configuration);
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* HttpFirewall 설정
|
||||
* 한글을 포함한 모든 문자를 헤더 값으로 허용
|
||||
*/
|
||||
@Bean
|
||||
public HttpFirewall allowUrlEncodedSlashHttpFirewall() {
|
||||
StrictHttpFirewall firewall = new StrictHttpFirewall();
|
||||
|
||||
// 한글을 포함한 모든 문자를 허용하도록 설정
|
||||
firewall.setAllowedHeaderValues(header -> true);
|
||||
|
||||
// URL 인코딩된 슬래시 허용
|
||||
firewall.setAllowUrlEncodedSlash(true);
|
||||
|
||||
// 세미콜론 허용
|
||||
firewall.setAllowSemicolon(true);
|
||||
|
||||
return firewall;
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,6 +82,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
return path.startsWith("/actuator") ||
|
||||
path.startsWith("/swagger-ui") ||
|
||||
path.startsWith("/v3/api-docs") ||
|
||||
path.equals("/health");
|
||||
path.equals("/health") ||
|
||||
path.equals("/api/v1/auth/login");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,209 @@
|
||||
package com.unicorn.hgzero.user.config.ldap;
|
||||
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.common.exception.ErrorCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.ldap.core.AttributesMapper;
|
||||
import org.springframework.ldap.core.LdapTemplate;
|
||||
import org.springframework.ldap.filter.EqualsFilter;
|
||||
import org.springframework.ldap.filter.Filter;
|
||||
import org.springframework.ldap.support.LdapUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.naming.NamingException;
|
||||
import javax.naming.directory.Attributes;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* LDAP 인증 및 사용자 정보 조회 클래스
|
||||
*
|
||||
* LDAP 서버와의 인증 처리 및 사용자 속성 조회 기능 제공
|
||||
* - LDAPS (포트 636) 사용
|
||||
* - 타임아웃: 5초
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class LdapAuthenticator {
|
||||
|
||||
private final LdapTemplate ldapTemplate;
|
||||
|
||||
@Value("${spring.ldap.user-dn-pattern:uid={0},ou=people}")
|
||||
private String userDnPattern;
|
||||
|
||||
@Value("${spring.ldap.user-search-base:ou=people}")
|
||||
private String userSearchBase;
|
||||
|
||||
@Value("${spring.profiles.active:dev}")
|
||||
private String activeProfile;
|
||||
|
||||
/**
|
||||
* LDAP 인증 및 사용자 정보 조회
|
||||
*
|
||||
* @param username 사용자 ID
|
||||
* @param password 비밀번호
|
||||
* @return LDAP 사용자 정보
|
||||
* @throws BusinessException 인증 실패 시
|
||||
*/
|
||||
public LdapUserDetails validateCredentials(String username, String password) {
|
||||
log.debug("LDAP 인증 시도: username={}, profile={}", username, activeProfile);
|
||||
|
||||
// 개발 환경에서는 LDAP 인증 건너뛰기
|
||||
if ("dev".equals(activeProfile)) {
|
||||
log.info("개발 환경 - LDAP 인증 건너뛰기: username={}", username);
|
||||
return createDefaultUserDetails(username);
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. LDAP bind (인증)
|
||||
boolean authenticated = authenticate(username, password);
|
||||
if (!authenticated) {
|
||||
log.warn("LDAP 인증 실패: username={}", username);
|
||||
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED);
|
||||
}
|
||||
|
||||
log.info("LDAP 인증 성공: username={}", username);
|
||||
|
||||
// 2. 사용자 정보 조회
|
||||
LdapUserDetails userDetails = searchUser(username);
|
||||
log.debug("LDAP 사용자 정보 조회 완료: username={}, email={}", username, userDetails.getEmail());
|
||||
|
||||
return userDetails;
|
||||
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("LDAP 인증 중 오류 발생: username={}, error={}", username, e.getMessage(), e);
|
||||
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED, "LDAP 인증 중 오류가 발생했습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP 인증 수행
|
||||
*
|
||||
* @param username 사용자 ID
|
||||
* @param password 비밀번호
|
||||
* @return 인증 성공 여부
|
||||
*/
|
||||
private boolean authenticate(String username, String password) {
|
||||
try {
|
||||
// EqualsFilter를 사용하여 uid로 검색
|
||||
Filter filter = new EqualsFilter("uid", username);
|
||||
|
||||
// LDAP 인증 수행
|
||||
// Protocol: LDAPS (636), Timeout: 5s (LdapTemplate 설정에서 관리)
|
||||
boolean authenticated = ldapTemplate.authenticate(
|
||||
userSearchBase,
|
||||
filter.encode(),
|
||||
password
|
||||
);
|
||||
|
||||
return authenticated;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("LDAP bind 실패: username={}, error={}", username, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP에서 사용자 정보 조회
|
||||
*
|
||||
* 조회 속성:
|
||||
* - cn (Common Name): 사용자 이름
|
||||
* - mail: 이메일
|
||||
* - department: 부서
|
||||
* - title: 직급
|
||||
*
|
||||
* @param username 사용자 ID
|
||||
* @return LDAP 사용자 정보
|
||||
*/
|
||||
private LdapUserDetails searchUser(String username) {
|
||||
log.debug("LDAP 사용자 정보 조회 시작: username={}", username);
|
||||
|
||||
try {
|
||||
// uid로 사용자 검색
|
||||
Filter filter = new EqualsFilter("uid", username);
|
||||
|
||||
// 사용자 속성 조회
|
||||
List<LdapUserDetails> userList = ldapTemplate.search(
|
||||
userSearchBase,
|
||||
filter.encode(),
|
||||
new UserAttributesMapper(username)
|
||||
);
|
||||
|
||||
if (userList.isEmpty()) {
|
||||
log.warn("LDAP에서 사용자를 찾을 수 없음: username={}", username);
|
||||
// 사용자를 찾을 수 없어도 기본 정보로 생성
|
||||
return createDefaultUserDetails(username);
|
||||
}
|
||||
|
||||
return userList.get(0);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("LDAP 사용자 정보 조회 실패: username={}, error={}", username, e.getMessage(), e);
|
||||
// 오류 발생 시 기본 정보로 생성
|
||||
return createDefaultUserDetails(username);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 사용자 정보 생성 (LDAP 조회 실패 시)
|
||||
*
|
||||
* @param username 사용자 ID
|
||||
* @return 기본 사용자 정보
|
||||
*/
|
||||
private LdapUserDetails createDefaultUserDetails(String username) {
|
||||
return LdapUserDetails.builder()
|
||||
.userId(username)
|
||||
.username(username)
|
||||
.email(username + "@example.com")
|
||||
.department("미지정")
|
||||
.title("미지정")
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP 속성을 LdapUserDetails로 매핑하는 Mapper
|
||||
*/
|
||||
private static class UserAttributesMapper implements AttributesMapper<LdapUserDetails> {
|
||||
private final String userId;
|
||||
|
||||
public UserAttributesMapper(String userId) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public LdapUserDetails mapFromAttributes(Attributes attributes) throws NamingException {
|
||||
return LdapUserDetails.builder()
|
||||
.userId(userId)
|
||||
.username(getAttributeValue(attributes, "cn", userId))
|
||||
.email(getAttributeValue(attributes, "mail", userId + "@example.com"))
|
||||
.department(getAttributeValue(attributes, "department", "미지정"))
|
||||
.title(getAttributeValue(attributes, "title", "미지정"))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 속성 값 조회 (속성이 없으면 기본값 반환)
|
||||
*
|
||||
* @param attributes 속성 목록
|
||||
* @param attributeName 속성 이름
|
||||
* @param defaultValue 기본값
|
||||
* @return 속성 값
|
||||
*/
|
||||
private String getAttributeValue(Attributes attributes, String attributeName, String defaultValue) {
|
||||
try {
|
||||
if (attributes.get(attributeName) != null) {
|
||||
Object value = attributes.get(attributeName).get();
|
||||
return value != null ? value.toString() : defaultValue;
|
||||
}
|
||||
} catch (NamingException e) {
|
||||
// 속성이 없거나 오류 발생 시 기본값 반환
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.unicorn.hgzero.user.config.ldap;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* LDAP에서 조회한 사용자 정보 DTO
|
||||
*
|
||||
* LDAP 인증 성공 후 사용자 속성 정보를 담는 클래스
|
||||
*/
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class LdapUserDetails {
|
||||
|
||||
/**
|
||||
* 사용자 ID (uid)
|
||||
*/
|
||||
private String userId;
|
||||
|
||||
/**
|
||||
* 사용자 이름 (cn - Common Name)
|
||||
*/
|
||||
private String username;
|
||||
|
||||
/**
|
||||
* 이메일 (mail)
|
||||
*/
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 부서 (department)
|
||||
*/
|
||||
private String department;
|
||||
|
||||
/**
|
||||
* 직급 (title)
|
||||
*/
|
||||
private String title;
|
||||
}
|
||||
@ -138,4 +138,15 @@ public class UserEntity extends BaseTimeEntity {
|
||||
this.failedLoginAttempts = 0;
|
||||
this.lastLoginAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP 정보로 사용자 정보 업데이트
|
||||
*
|
||||
* @param username 사용자 이름
|
||||
* @param email 이메일
|
||||
*/
|
||||
public void updateFromLdap(String username, String email) {
|
||||
this.username = username;
|
||||
this.email = email;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ package com.unicorn.hgzero.user.service;
|
||||
import com.unicorn.hgzero.common.exception.BusinessException;
|
||||
import com.unicorn.hgzero.common.exception.ErrorCode;
|
||||
import com.unicorn.hgzero.common.security.JwtTokenProvider;
|
||||
import com.unicorn.hgzero.user.config.ldap.LdapAuthenticator;
|
||||
import com.unicorn.hgzero.user.config.ldap.LdapUserDetails;
|
||||
import com.unicorn.hgzero.user.domain.User;
|
||||
import com.unicorn.hgzero.user.dto.*;
|
||||
import com.unicorn.hgzero.user.repository.entity.UserEntity;
|
||||
@ -37,7 +39,7 @@ import io.jsonwebtoken.security.Keys;
|
||||
@RequiredArgsConstructor
|
||||
public class UserServiceImpl implements UserService {
|
||||
|
||||
private final LdapTemplate ldapTemplate;
|
||||
private final LdapAuthenticator ldapAuthenticator;
|
||||
private final UserRepository userRepository;
|
||||
private final JwtTokenProvider jwtTokenProvider;
|
||||
private final RedisTemplate<String, String> redisTemplate;
|
||||
@ -64,7 +66,7 @@ public class UserServiceImpl implements UserService {
|
||||
public LoginResponse login(LoginRequest request) {
|
||||
log.info("로그인 시도: userId={}", request.getUserId());
|
||||
|
||||
// 사용자 조회 또는 생성
|
||||
// 사용자 조회
|
||||
UserEntity userEntity = userRepository.findById(request.getUserId())
|
||||
.orElse(null);
|
||||
|
||||
@ -81,10 +83,12 @@ public class UserServiceImpl implements UserService {
|
||||
}
|
||||
}
|
||||
|
||||
// LDAP 인증
|
||||
// LDAP 인증 및 사용자 정보 조회
|
||||
LdapUserDetails ldapUserDetails;
|
||||
try {
|
||||
authenticateWithLdap(request.getUserId(), request.getPassword());
|
||||
log.info("LDAP 인증 성공: userId={}", request.getUserId());
|
||||
ldapUserDetails = ldapAuthenticator.validateCredentials(request.getUserId(), request.getPassword());
|
||||
log.info("LDAP 인증 성공: userId={}, username={}, email={}",
|
||||
request.getUserId(), ldapUserDetails.getUsername(), ldapUserDetails.getEmail());
|
||||
} catch (Exception e) {
|
||||
log.error("LDAP 인증 실패: userId={}, error={}", request.getUserId(), e.getMessage());
|
||||
|
||||
@ -99,15 +103,26 @@ public class UserServiceImpl implements UserService {
|
||||
|
||||
// 사용자 정보 조회 또는 생성
|
||||
if (userEntity == null) {
|
||||
// LDAP에서 추가 정보 조회 (실제 환경에서는 LDAP에서 이메일 등을 가져와야 함)
|
||||
// LDAP에서 조회한 정보로 신규 사용자 등록
|
||||
log.info("신규 사용자 등록: userId={}, username={}, email={}, department={}, title={}",
|
||||
ldapUserDetails.getUserId(), ldapUserDetails.getUsername(),
|
||||
ldapUserDetails.getEmail(), ldapUserDetails.getDepartment(), ldapUserDetails.getTitle());
|
||||
|
||||
userEntity = UserEntity.builder()
|
||||
.userId(request.getUserId())
|
||||
.username(request.getUserId()) // LDAP에서 가져온 실제 이름 사용
|
||||
.email(request.getUserId() + "@example.com") // LDAP에서 가져온 실제 이메일 사용
|
||||
.userId(ldapUserDetails.getUserId())
|
||||
.username(ldapUserDetails.getUsername())
|
||||
.email(ldapUserDetails.getEmail())
|
||||
.authority("USER")
|
||||
.locked(false)
|
||||
.failedLoginAttempts(0)
|
||||
.build();
|
||||
} else {
|
||||
// 기존 사용자의 경우 LDAP 정보로 업데이트 (이메일, 이름 등이 변경될 수 있음)
|
||||
log.debug("기존 사용자 정보 업데이트: userId={}", request.getUserId());
|
||||
userEntity.updateFromLdap(
|
||||
ldapUserDetails.getUsername(),
|
||||
ldapUserDetails.getEmail()
|
||||
);
|
||||
}
|
||||
|
||||
// 로그인 성공 기록
|
||||
@ -251,22 +266,6 @@ public class UserServiceImpl implements UserService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP 인증 수행
|
||||
*/
|
||||
private void authenticateWithLdap(String userId, String password) {
|
||||
// LDAP DN 생성
|
||||
String userDn = userDnPattern.replace("{0}", userId);
|
||||
|
||||
// LDAP 인증
|
||||
Filter filter = new EqualsFilter("uid", userId);
|
||||
boolean authenticated = ldapTemplate.authenticate("", filter.encode(), password);
|
||||
|
||||
if (!authenticated) {
|
||||
throw new BusinessException(ErrorCode.AUTHENTICATION_FAILED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Access Token 생성
|
||||
*/
|
||||
|
||||
@ -8,7 +8,7 @@ spring:
|
||||
datasource:
|
||||
url: jdbc:${DB_KIND:postgresql}://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:userdb}
|
||||
username: ${DB_USERNAME:hgzerouser}
|
||||
password: ${DB_PASSWORD:Hi5Jessica!}
|
||||
password: ${DB_PASSWORD:}
|
||||
driver-class-name: org.postgresql.Driver
|
||||
hikari:
|
||||
maximum-pool-size: 20
|
||||
@ -27,7 +27,7 @@ spring:
|
||||
use_sql_comments: true
|
||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||
jdbc:
|
||||
time_zone: UTC
|
||||
time_zone: Asia/Seoul
|
||||
hibernate:
|
||||
ddl-auto: ${JPA_DDL_AUTO:update}
|
||||
database: postgresql
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user