This commit is contained in:
cyjadela 2025-10-23 15:36:29 +09:00
commit b8cd502c44
42 changed files with 5038 additions and 38 deletions

View File

@ -2,10 +2,8 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit -m \"$(cat <<''EOF''\n유저스토리 및 프로토타입 업데이트 (v2.0.1)\n\n- 공유 기능 제거 반영\n - AFR-USER-020: 대시보드 \"공유받은 회의록\" 섹션 제거\n - UFR-MEET-046: 회의록 목록 카테고리 필터 \"공유받은 회의\" 제거\n \n- 모바일 헤더 프로필 아바타 통일\n - 데스크탑 사이드바와 동일한 아바타 스타일 적용\n - 프로토타입 3개 파일 업데이트 (02-대시보드, 09-Todo관리, 12-회의록목록조회)\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")", "Bash(git commit -m \"$(cat <<''EOF''\nTodo 수정 기능 개선 (UFR-TODO-040)\n\n- 09-Todo관리 프로토타입: 권한별 담당자 필드 표시/숨김 기능 추가\n - 일반 담당자: 담당자 필드 숨김 (본인 Todo만 수정)\n - 회의 생성자: 담당자 필드 표시 (모든 Todo 수정 가능)\n- 담당자 변경 시 알림 발송 로직 추가\n- checkIfUserIsCreator() 함수 추가 (회의 생성자 권한 확인)\n- 권한별 동적 UI 메시지 표시\n- 설계서 Option 1 준수: 09-Todo관리에서 일반 담당자는 담당자 변경 불가\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")",
"Bash(git push)", "Bash(git push)"
"Bash(git pull:*)",
"Bash(git commit:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

Binary file not shown.

View File

@ -0,0 +1,2 @@
#Thu Oct 23 15:10:42 KST 2025
gradle.version=8.14

View File

View File

@ -39,35 +39,49 @@ spring:
max-idle: 8 max-idle: 8
min-idle: 0 min-idle: 0
max-wait: -1ms max-wait: -1ms
database: ${REDIS_DATABASE:3} database: ${REDIS_DATABASE:4}
# Server Configuration # Server Configuration
server: server:
port: ${SERVER_PORT:8083} port: ${SERVER_PORT:8084}
servlet:
context-path: ${CONTEXT_PATH:}
# JWT Configuration # JWT Configuration
jwt: jwt:
secret: ${JWT_SECRET:} secret: ${JWT_SECRET:}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600} access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800} refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
# CORS Configuration # CORS Configuration
cors: cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*} allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
# OpenAI Configuration # Azure OpenAI Configuration
openai:
api-key: ${OPENAI_API_KEY:}
model: ${OPENAI_MODEL:gpt-4}
max-tokens: ${OPENAI_MAX_TOKENS:2000}
temperature: ${OPENAI_TEMPERATURE:0.7}
# Azure AI Search Configuration
azure: azure:
aisearch: openai:
endpoint: ${AZURE_AISEARCH_ENDPOINT:} api-key: ${AZURE_OPENAI_API_KEY:}
api-key: ${AZURE_AISEARCH_API_KEY:} endpoint: ${AZURE_OPENAI_ENDPOINT:}
index-name: ${AZURE_AISEARCH_INDEX_NAME:minutes-index} deployment-name: ${AZURE_OPENAI_DEPLOYMENT:gpt-4o}
embedding-deployment: ${AZURE_OPENAI_EMBEDDING_DEPLOYMENT:text-embedding-3-large}
max-tokens: ${AZURE_OPENAI_MAX_TOKENS:2000}
temperature: ${AZURE_OPENAI_TEMPERATURE:0.3}
# Azure AI Search Configuration
ai-search:
endpoint: ${AZURE_AI_SEARCH_ENDPOINT:}
api-key: ${AZURE_AI_SEARCH_API_KEY:}
index-name: ${AZURE_AI_SEARCH_INDEX:meeting-transcripts}
# Azure Event Hubs Configuration
eventhub:
connection-string: ${AZURE_EVENTHUB_CONNECTION_STRING:}
namespace: ${AZURE_EVENTHUB_NAMESPACE:hgzero-eventhub-ns}
checkpoint-storage-connection-string: ${AZURE_CHECKPOINT_STORAGE_CONNECTION_STRING:}
checkpoint-container: ${AZURE_CHECKPOINT_CONTAINER:hgzero-checkpoints}
consumer-group:
transcript: ${AZURE_EVENTHUB_CONSUMER_GROUP_TRANSCRIPT:ai-transcript-group}
meeting: ${AZURE_EVENTHUB_CONSUMER_GROUP_MEETING:ai-meeting-group}
# Actuator Configuration # Actuator Configuration
management: management:

File diff suppressed because one or more lines are too long

View File

@ -555,6 +555,60 @@
</div> </div>
</div> </div>
<!-- Todo 편집 모달 (UFR-TODO-040) -->
<div class="modal-overlay" id="editTodoModal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Todo 편집</h3>
<button class="modal-close">×</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Todo 제목 <span class="text-error">*</span></label>
<input type="text" id="editTodoTitle" class="form-control" placeholder="할 일을 입력하세요">
</div>
<!-- 담당자 필드 (회의 생성자만 표시) -->
<div class="form-group" id="editTodoAssigneeGroup" style="display: none;">
<label class="form-label">담당자 <span class="text-error">*</span></label>
<select id="editTodoAssignee" class="form-control">
<option value="김민준">김민준</option>
<option value="이서연">이서연</option>
<option value="박준호">박준호</option>
<option value="정수진">정수진</option>
</select>
<p class="form-hint">👤 담당자 변경 시 이전/새 담당자에게 알림이 전송됩니다</p>
</div>
<div class="form-group">
<label class="form-label">마감일 <span class="text-error">*</span></label>
<div class="date-input-wrapper">
<input type="date" id="editTodoDueDate" class="form-control">
</div>
<p class="form-hint">📅 마감일 변경 시 캘린더가 자동 업데이트됩니다</p>
</div>
<div class="form-group">
<label class="form-label">우선순위 <span class="text-error">*</span></label>
<select id="editTodoPriority" class="form-control">
<option value="high">높음</option>
<option value="medium">보통</option>
<option value="low">낮음</option>
</select>
</div>
<!-- 권한 안내 (동적 메시지) -->
<div class="alert alert-info" id="editTodoPermissionInfo">
<span class="material-icons" style="font-size: 20px;">info</span>
<div>
<strong>권한 안내</strong>
<p style="margin: 4px 0 0 0; font-size: 14px;" id="editTodoPermissionText">본인에게 할당된 Todo만 수정할 수 있습니다. 담당자는 변경할 수 없습니다.</p>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" onclick="closeModal('editTodoModal')">취소</button>
<button class="btn btn-primary" onclick="saveTodoEdit()">저장</button>
</div>
</div>
</div>
<script src="common.js"></script> <script src="common.js"></script>
<script> <script>
let currentFilter = 'all'; let currentFilter = 'all';
@ -674,6 +728,14 @@
${createProgressBar(todo.progress)} ${createProgressBar(todo.progress)}
</div> </div>
` : ''} ` : ''}
${!isCompleted ? `
<div class="todo-actions">
<button class="btn btn-ghost btn-sm" onclick="editTodo('${todo.id}')">
<span class="material-icons" style="font-size: 16px;">edit</span>
편집
</button>
</div>
` : ''}
</div> </div>
</div> </div>
`); `);
@ -735,6 +797,132 @@
// 실제로는 폼 데이터를 수집하여 allTodos에 추가 // 실제로는 폼 데이터를 수집하여 allTodos에 추가
} }
// Todo 편집 (UFR-TODO-040)
let editingTodoId = null;
function editTodo(todoId) {
const todo = allTodos.find(t => t.id === todoId);
if (!todo) return;
editingTodoId = todoId;
// 편집 모달에 현재 값 채우기
$('#editTodoTitle').value = todo.title;
$('#editTodoDueDate').value = todo.dueDate;
$('#editTodoPriority').value = todo.priority;
// 회의 생성자 여부 확인 (실제로는 서버에서 확인)
const currentUser = '김민준'; // 현재 로그인 사용자
const isCreator = checkIfUserIsCreator(todo.meetingId, currentUser);
// 담당자 필드 표시 여부 결정
const assigneeGroup = $('#editTodoAssigneeGroup');
const permissionText = $('#editTodoPermissionText');
if (isCreator) {
// 회의 생성자: 담당자 변경 가능
assigneeGroup.style.display = 'block';
$('#editTodoAssignee').value = todo.assignee.name;
permissionText.textContent = '회의 생성자로서 모든 항목을 수정할 수 있습니다. 담당자 변경 시 알림이 전송됩니다.';
} else {
// 일반 담당자: 담당자 변경 불가
assigneeGroup.style.display = 'none';
permissionText.textContent = '본인에게 할당된 Todo만 수정할 수 있습니다. 담당자는 변경할 수 없습니다.';
}
openModal('editTodoModal');
}
/**
* 회의 생성자 여부 확인
* @param {string} meetingId - 회의 ID
* @param {string} userName - 사용자 이름
* @returns {boolean} 회의 생성자 여부
*/
function checkIfUserIsCreator(meetingId, userName) {
// 실제로는 서버 API를 호출하여 확인
// 프로토타입에서는 샘플 데이터로 시뮬레이션
const meetingCreators = {
'meeting-001': '김민준',
'meeting-002': '이서연',
'meeting-003': '박준호'
};
return meetingCreators[meetingId] === userName;
}
function saveTodoEdit() {
if (!editingTodoId) return;
const todo = allTodos.find(t => t.id === editingTodoId);
if (!todo) return;
// 수정된 값 가져오기
const newTitle = $('#editTodoTitle').value.trim();
const newDueDate = $('#editTodoDueDate').value;
const newPriority = $('#editTodoPriority').value;
// 유효성 검사
if (!newTitle) {
showToast('Todo 제목을 입력해주세요', 'error');
return;
}
if (!newDueDate) {
showToast('마감일을 선택해주세요', 'error');
return;
}
// 회의 생성자 여부 확인
const currentUser = '김민준';
const isCreator = checkIfUserIsCreator(todo.meetingId, currentUser);
// 담당자 변경 여부 확인 (회의 생성자만 가능)
let assigneeChanged = false;
let oldAssignee = '';
let newAssignee = '';
if (isCreator) {
const assigneeGroup = $('#editTodoAssigneeGroup');
if (assigneeGroup.style.display !== 'none') {
newAssignee = $('#editTodoAssignee').value;
oldAssignee = todo.assignee.name;
assigneeChanged = (oldAssignee !== newAssignee);
}
}
// Todo 업데이트
const oldDueDate = todo.dueDate;
todo.title = newTitle;
todo.dueDate = newDueDate;
todo.priority = newPriority;
if (assigneeChanged) {
todo.assignee.name = newAssignee;
}
showToast('Todo가 수정되었습니다', 'success');
closeModal('editTodoModal');
// 담당자 변경 시 알림 발송
if (assigneeChanged) {
setTimeout(() => {
showToast(`${oldAssignee}와 ${newAssignee}에게 알림이 전송되었습니다`, 'info');
}, 1000);
}
// 마감일 변경 시 캘린더 업데이트 메시지
if (oldDueDate !== newDueDate) {
setTimeout(() => {
showToast('캘린더가 업데이트되었습니다', 'info');
}, assigneeChanged ? 2000 : 1000);
}
updateStats();
renderTodoList();
editingTodoId = null;
}
// 카운터 애니메이션 // 카운터 애니메이션
function animateCounter(elementId, target) { function animateCounter(elementId, target) {
const element = $(`#${elementId}`); const element = $(`#${elementId}`);

View File

@ -2,9 +2,9 @@
## 문서 정보 ## 문서 정보
- **작성일**: 2025-10-21 - **작성일**: 2025-10-21
- **최종 수정일**: 2025-10-22 - **최종 수정일**: 2025-10-23
- **작성자**: 이미준 (서비스 기획자) - **작성자**: 이미준 (서비스 기획자)
- **버전**: 1.4 - **버전**: 1.4.4
- **설계 철학**: Mobile First Design - **설계 철학**: Mobile First Design
--- ---
@ -1091,16 +1091,17 @@ graph TD
#### 개요 #### 개요
- **목적**: 할당된 Todo 목록 조회 및 진행 상황 관리 - **목적**: 할당된 Todo 목록 조회 및 진행 상황 관리
- **관련 유저스토리**: UFR-TODO-010, UFR-TODO-030 - **관련 유저스토리**: UFR-TODO-010, UFR-TODO-030, **UFR-TODO-040 (Todo 수정)**
- **비즈니스 중요도**: 높음 - **비즈니스 중요도**: 높음
- **접근 경로**: 대시보드 → 하단 네비게이션 "Todo" 또는 대시보드 "내 Todo" 카드 → "전체 보기" - **접근 경로**: 대시보드 → 하단 네비게이션 "Todo" 또는 대시보드 "내 Todo" 카드 → "전체 보기"
#### 주요 기능 #### 주요 기능
1. Todo 목록 표시 (상태별 필터링) 1. Todo 목록 표시 (상태별 필터링)
2. Todo 완료 처리 2. **Todo 수정 (UFR-TODO-040)** - 신규 추가
3. 회의록 원문으로 이동 (양방향 연결) 3. Todo 완료 처리
4. Todo 진행 상황 통계 4. 회의록 원문으로 이동 (양방향 연결)
5. 마감 임박 Todo 알림 5. Todo 진행 상황 통계
6. 마감 임박 Todo 알림
#### UI 구성요소 #### UI 구성요소
@ -1124,6 +1125,7 @@ graph TD
- 마감일 (색상 코딩: 초록-여유, 노랑-임박, 빨강-지연) - 마감일 (색상 코딩: 초록-여유, 노랑-임박, 빨강-지연)
- 우선순위 배지 (높음/보통/낮음) - 우선순위 배지 (높음/보통/낮음)
- 관련 회의록 링크 아이콘 - 관련 회의록 링크 아이콘
- **"편집" 버튼** (본인 담당 Todo만 표시) - 신규
- 스와이프 액션: 수정, 삭제 - 스와이프 액션: 수정, 삭제
- **FAB** (Floating Action Button) - **FAB** (Floating Action Button)
@ -1140,23 +1142,41 @@ graph TD
- 진행 메모 (추가 가능) - 진행 메모 (추가 가능)
#### 인터랙션 #### 인터랙션
1. **Todo 완료 처리** 1. **Todo 수정 (UFR-TODO-040)** - 신규 추가
- **편집 버튼 클릭**:
- 인라인 편집 모드로 전환 또는 수정 모달 표시
- **수정 가능 항목** (담당자 권한):
- ✏️ Todo 제목 (문구)
- 📅 마감일 (날짜 선택기)
- 🎯 우선순위 (high/medium/low 드롭다운)
- ❌ 담당자 변경 불가 (본인 담당 Todo)
- **저장 버튼**: 수정 완료
- **취소 버튼**: 편집 모드 취소
- **수정 완료 시**:
- "Todo가 수정되었습니다" 토스트 메시지
- 회의록에 수정 내용 실시간 반영
- 마감일 변경 시 캘린더 자동 업데이트
- **권한 제어**:
- 본인에게 할당된 Todo만 편집 버튼 표시
- 다른 사람의 Todo는 조회만 가능 (편집 버튼 숨김)
2. **Todo 완료 처리**
- 체크박스 클릭: - 체크박스 클릭:
- 확인 다이얼로그 ("완료 처리하시겠습니까?") - 확인 다이얼로그 ("완료 처리하시겠습니까?")
- 완료 시: 체크 애니메이션, 회의록에 실시간 반영 - 완료 시: 체크 애니메이션, 회의록에 실시간 반영
- 완료 Todo는 리스트 하단으로 이동 (취소선) - 완료 Todo는 리스트 하단으로 이동 (취소선)
2. **회의록 연결** 3. **회의록 연결**
- 회의록 링크 아이콘 클릭: - 회의록 링크 아이콘 클릭:
- 회의록상세조회 화면으로 이동 - 회의록상세조회 화면으로 이동
- 해당 Todo가 언급된 섹션으로 자동 스크롤 - 해당 Todo가 언급된 섹션으로 자동 스크롤
- 하이라이트 효과 - 하이라이트 효과
3. **필터링** 4. **필터링**
- 필터 탭 클릭: 해당 상태의 Todo만 표시 - 필터 탭 클릭: 해당 상태의 Todo만 표시
- 마감 임박: 3일 이내 마감 Todo - 마감 임박: 3일 이내 마감 Todo
4. **수동 추가** 5. **수동 추가**
- FAB 클릭: Todo 추가 모달 - FAB 클릭: Todo 추가 모달
- 내용, 마감일, 우선순위 입력 - 내용, 마감일, 우선순위 입력
@ -1358,7 +1378,7 @@ graph TD
#### 개요 #### 개요
- **목적**: 지난 회의록 조회 및 수정 - **목적**: 지난 회의록 조회 및 수정
- **관련 유저스토리**: UFR-MEET-055, UFR-AI-040 - **관련 유저스토리**: UFR-MEET-055, UFR-AI-040, **UFR-TODO-040 (Todo 수정)**
- **비즈니스 중요도**: 중간 - **비즈니스 중요도**: 중간
- **접근 경로**: 대시보드 → "내 회의록" → 회의록상세조회 → "수정" - **접근 경로**: 대시보드 → "내 회의록" → 회의록상세조회 → "수정"
@ -1367,9 +1387,10 @@ graph TD
2. 회의록 내용 수정 (섹션별) 2. 회의록 내용 수정 (섹션별)
3. **AI 요약 수정** (섹션별) 3. **AI 요약 수정** (섹션별)
4. **참고자료 편집** (추가/제거) 4. **참고자료 편집** (추가/제거)
5. 자동 저장 (30초 간격) 5. **Todo 수정 (UFR-TODO-040)** - 신규 추가 (회의 생성자만)
6. 수정 이력 관리 6. 자동 저장 (30초 간격)
7. 상태 변경 (확정완료 → 작성중) 7. 수정 이력 관리
8. 상태 변경 (확정완료 → 작성중)
#### UI 구성요소 #### UI 구성요소
@ -1407,6 +1428,17 @@ graph TD
- 기존 참고자료 목록 (제거 버튼 포함) - 기존 참고자료 목록 (제거 버튼 포함)
- "참고자료 추가" 버튼 - "참고자료 추가" 버튼
- 회의록 검색 및 선택 UI - 회의록 검색 및 선택 UI
- **Todo 섹션 편집 영역** (회의 생성자만) - 신규
- Todo 목록 표시
- 각 Todo 항목:
- 체크박스 (완료 상태)
- Todo 제목
- 담당자 (변경 가능)
- 마감일 (변경 가능)
- 우선순위 (변경 가능)
- "편집" 버튼 (인라인 편집 활성화)
- "삭제" 버튼
- "Todo 추가" 버튼
- 검증 완료 체크박스 (잠금 해제 필요) - 검증 완료 체크박스 (잠금 해제 필요)
- 자동 저장 상태 표시 ("저장됨", "저장 중...") - 자동 저장 상태 표시 ("저장됨", "저장 중...")
@ -1446,7 +1478,35 @@ graph TD
- 제거 버튼 (X): 참고자료 목록에서 제거 - 제거 버튼 (X): 참고자료 목록에서 제거
- 순서 변경: 드래그하여 순서 조정 (선택) - 순서 변경: 드래그하여 순서 조정 (선택)
6. **상태 변경** 6. **Todo 섹션 편집 (UFR-TODO-040)** - 신규 추가 (회의 생성자만)
- **권한 제어**:
- 회의 생성자만 Todo 섹션 편집 가능
- 일반 참석자는 조회만 가능 (편집 버튼 숨김)
- **편집 버튼 클릭**:
- 인라인 편집 모드 활성화
- **수정 가능 항목** (회의 생성자 권한):
- ✏️ Todo 제목
- 👤 담당자 (드롭다운 선택, 참석자 목록)
- 📅 마감일 (날짜 선택기)
- 🎯 우선순위 (high/medium/low)
- "저장" 버튼: 수정 완료
- "취소" 버튼: 편집 모드 취소
- **수정 완료 시**:
- "Todo가 수정되었습니다" 토스트 메시지
- 회의록 자동 저장
- 담당자 변경 시: 이전/새 담당자에게 알림 발송
- 마감일 변경 시: 캘린더 자동 업데이트
- **Todo 추가**:
- "Todo 추가" 버튼 클릭
- Todo 정보 입력 모달 (제목, 담당자, 마감일, 우선순위)
- 저장 시 Todo 목록에 추가
- **Todo 삭제**:
- "삭제" 버튼 클릭
- 확인 다이얼로그 ("삭제하시겠습니까?")
- 삭제 시 Todo 목록에서 제거
- 담당자에게 삭제 알림 발송
7. **상태 변경**
- 확정완료 회의록 수정 시: 자동으로 "작성중" 상태로 변경 - 확정완료 회의록 수정 시: 자동으로 "작성중" 상태로 변경
- 모든 섹션 검증 완료 시: "확정완료"로 변경 제안 - 모든 섹션 검증 완료 시: "확정완료"로 변경 제안
@ -1943,6 +2003,7 @@ graph TD
| 1.4.1 | 2025-10-23 | 강지수 | 대시보드 모바일 UI/UX 개선 (360px 최적화)<br>- **헤더 개선안 A 적용**: 간결한 인사 + 실질적 정보<br> - "안녕하세요 👋" (H3, Bold)<br> - "오늘 {N}건의 회의가 예정되어 있어요" (동적 업데이트)<br> - 2줄 구조 제거로 세로 공간 절약<br>- **통계 카드 개선안 A 적용**: 컴팩트 수평 배치<br> - 단일 카드 "📊 오늘의 현황" (H5, Semibold)<br> - 수평 배치: "📅 예정 {N}", "✅ 진행 {N}", "📈 완료 {N}%"<br> - 높이 ~80px (기존 대비 70% 감소)<br> - 반응형: 태블릿 이상에서 justify-content: flex-start<br>- **프로토타입 파일**: design/uiux/prototype/02-대시보드-개선.html 신규 생성<br>- **모바일 우선 반응형 설계**: 웹/태블릿 화면에서도 자연스러운 레이아웃 유지<br>- **참조**: design/uiux/ref_img/레이아웃 이상.png (개선 요구사항 이미지) | | 1.4.1 | 2025-10-23 | 강지수 | 대시보드 모바일 UI/UX 개선 (360px 최적화)<br>- **헤더 개선안 A 적용**: 간결한 인사 + 실질적 정보<br> - "안녕하세요 👋" (H3, Bold)<br> - "오늘 {N}건의 회의가 예정되어 있어요" (동적 업데이트)<br> - 2줄 구조 제거로 세로 공간 절약<br>- **통계 카드 개선안 A 적용**: 컴팩트 수평 배치<br> - 단일 카드 "📊 오늘의 현황" (H5, Semibold)<br> - 수평 배치: "📅 예정 {N}", "✅ 진행 {N}", "📈 완료 {N}%"<br> - 높이 ~80px (기존 대비 70% 감소)<br> - 반응형: 태블릿 이상에서 justify-content: flex-start<br>- **프로토타입 파일**: design/uiux/prototype/02-대시보드-개선.html 신규 생성<br>- **모바일 우선 반응형 설계**: 웹/태블릿 화면에서도 자연스러운 레이아웃 유지<br>- **참조**: design/uiux/ref_img/레이아웃 이상.png (개선 요구사항 이미지) |
| 1.4.2 | 2025-10-23 | 강지수 | 회의록 공유 기능 전면 제거<br>- **제거 배경**: 회의 참가자가 아니면 대상자 선정 불가능, 기능 중복 및 논리적 모순 해결<br>- **유저스토리**: UFR-MEET-060 (회의록공유) 제거<br>- **UI/UX 설계서**:<br> - 08-회의록공유 화면 전체 제거<br> - 02-대시보드: "공유받은 회의록" 섹션 제거<br> - 09-회의록상세조회: 공유 버튼 제거 (메뉴: 수정/삭제만 유지)<br> - 11-회의록목록조회: 카테고리 필터 수정 (전체/참석한 회의/생성한 회의)<br> - Desktop 사이드바: "공유받은 회의록" 메뉴 제거<br>- **화면 번호 재정렬**: 08-Todo관리, 09-회의록상세조회, 10-회의록수정, 11-회의록목록조회<br>- **프로토타입 파일**: 08-회의록공유.html 삭제 예정<br>- **검토 문서**: design/uiux/crosscheck-report.md (상세 검토 의견 및 수정 계획) | | 1.4.2 | 2025-10-23 | 강지수 | 회의록 공유 기능 전면 제거<br>- **제거 배경**: 회의 참가자가 아니면 대상자 선정 불가능, 기능 중복 및 논리적 모순 해결<br>- **유저스토리**: UFR-MEET-060 (회의록공유) 제거<br>- **UI/UX 설계서**:<br> - 08-회의록공유 화면 전체 제거<br> - 02-대시보드: "공유받은 회의록" 섹션 제거<br> - 09-회의록상세조회: 공유 버튼 제거 (메뉴: 수정/삭제만 유지)<br> - 11-회의록목록조회: 카테고리 필터 수정 (전체/참석한 회의/생성한 회의)<br> - Desktop 사이드바: "공유받은 회의록" 메뉴 제거<br>- **화면 번호 재정렬**: 08-Todo관리, 09-회의록상세조회, 10-회의록수정, 11-회의록목록조회<br>- **프로토타입 파일**: 08-회의록공유.html 삭제 예정<br>- **검토 문서**: design/uiux/crosscheck-report.md (상세 검토 의견 및 수정 계획) |
| 1.4.3 | 2025-10-23 | 강지수 | 유저스토리-설계서-프로토타입 일관성 개선 (요구사항설계검토-report.md 반영)<br>- **화면번호 프로토타입 파일명 기준 통일**:<br> - 프로토타입 화면 목록 테이블 화면번호 수정<br> - 09: Todo관리 (09-Todo관리.html) - 변경 없음<br> - 10: 회의록상세조회 (10-회의록상세조회.html) - 변경 없음<br> - 11: 회의록수정 (11-회의록수정.html) - 09→11 변경<br> - 12: 회의록목록조회 (12-회의록목록조회.html) - 11→12 변경<br> - 설계서 본문 섹션 제목 화면번호 수정<br> - ### 09-Todo관리 (08→09 변경)<br> - ### 10-회의록상세조회 (변경 없음)<br> - ### 11-회의록수정 (10→11 변경)<br> - ### 12-회의록목록조회 (11→12 변경)<br>- **유저스토리 화면정보 추가 및 수정**:<br> - UFR-MEET-046 (회의록목록조회): 화면번호 "12-회의록목록조회" 추가, 카테고리 필터에서 "공유받은 회의" 제거<br> - UFR-MEET-047 (회의록상세조회): 화면번호 "10-회의록상세조회" 추가, 관련 유저스토리 ID 수정 (UFR-MEET-045 → UFR-MEET-047)<br>- **설계서 유저스토리 매핑 정확성 개선**:<br> - 10-회의록상세조회: UFR-MEET-045 → UFR-MEET-047 수정<br> - 12-회의록목록조회: UFR-MEET-030, UFR-MEET-045 → UFR-MEET-046 수정<br>- **일관성 달성**: 유저스토리, UI/UX 설계서, 프로토타입 간 완전한 화면번호 및 파일명 일치<br>- **검토 문서**: design/uiux/요구사항설계검토-report.md (상세 검토 의견 및 개선 계획) | | 1.4.3 | 2025-10-23 | 강지수 | 유저스토리-설계서-프로토타입 일관성 개선 (요구사항설계검토-report.md 반영)<br>- **화면번호 프로토타입 파일명 기준 통일**:<br> - 프로토타입 화면 목록 테이블 화면번호 수정<br> - 09: Todo관리 (09-Todo관리.html) - 변경 없음<br> - 10: 회의록상세조회 (10-회의록상세조회.html) - 변경 없음<br> - 11: 회의록수정 (11-회의록수정.html) - 09→11 변경<br> - 12: 회의록목록조회 (12-회의록목록조회.html) - 11→12 변경<br> - 설계서 본문 섹션 제목 화면번호 수정<br> - ### 09-Todo관리 (08→09 변경)<br> - ### 10-회의록상세조회 (변경 없음)<br> - ### 11-회의록수정 (10→11 변경)<br> - ### 12-회의록목록조회 (11→12 변경)<br>- **유저스토리 화면정보 추가 및 수정**:<br> - UFR-MEET-046 (회의록목록조회): 화면번호 "12-회의록목록조회" 추가, 카테고리 필터에서 "공유받은 회의" 제거<br> - UFR-MEET-047 (회의록상세조회): 화면번호 "10-회의록상세조회" 추가, 관련 유저스토리 ID 수정 (UFR-MEET-045 → UFR-MEET-047)<br>- **설계서 유저스토리 매핑 정확성 개선**:<br> - 10-회의록상세조회: UFR-MEET-045 → UFR-MEET-047 수정<br> - 12-회의록목록조회: UFR-MEET-030, UFR-MEET-045 → UFR-MEET-046 수정<br>- **일관성 달성**: 유저스토리, UI/UX 설계서, 프로토타입 간 완전한 화면번호 및 파일명 일치<br>- **검토 문서**: design/uiux/요구사항설계검토-report.md (상세 검토 의견 및 개선 계획) |
| 1.4.4 | 2025-10-23 | 강지수, 도그냥 | Todo 수정 기능 추가 (UFR-TODO-040)<br>- **유저스토리**: UFR-TODO-040 (Todo수정) 신규 추가<br> - 회의록 확정 전/후 Todo 수정 기능<br> - 권한별 수정 범위: 담당자(본인 Todo만), 회의 생성자(모든 Todo)<br> - 수정 항목: 제목, 담당자, 마감일, 우선순위<br>- **09-Todo관리**: Todo 수정 기능 추가<br> - "편집" 버튼 추가 (완료되지 않은 본인 Todo만 표시)<br> - Todo 편집 모달: 제목, 마감일, 우선순위 수정 (담당자 변경 불가)<br> - 수정 완료 시 회의록에 실시간 반영, 마감일 변경 시 캘린더 자동 업데이트<br> - 권한 제어: 본인에게 할당된 Todo만 편집 버튼 표시<br>- **11-회의록수정**: Todo 섹션 편집 기능 추가 (회의 생성자만)<br> - Todo 목록 표시 및 인라인 편집 (제목, 담당자, 마감일, 우선순위)<br> - Todo 추가/삭제 기능<br> - 담당자 변경 시 이전/새 담당자에게 알림 발송<br>- **프로토타입**: design/uiux/prototype/09-Todo관리.html 수정 (편집 모달 및 기능 구현) |
| 1.4.4 | 2025-10-23 | 강지수 | Mobile 하단 네비게이션 프로토타입 구현 기준 반영<br>- **Mobile 하단 네비게이션**: 4개 메뉴 → 3개 메뉴로 수정 (홈/회의록/Todo)<br> - 프로필 메뉴 제거 (Desktop 사이드바의 사용자 정보 영역으로 통합)<br> - 프로토타입 실제 구현 상태 반영 (02-대시보드.html, 09-Todo관리.html, 12-회의록목록조회.html)<br> - 사용 화면 번호 업데이트: 08→09, 11→12<br>- **참고 사항**: 프로필 메뉴가 필요한 경우 프로토타입에 추가 구현 필요<br>- **설계서-프로토타입 일관성**: 네비게이션 구조 완전 통일 달성 | | 1.4.4 | 2025-10-23 | 강지수 | Mobile 하단 네비게이션 프로토타입 구현 기준 반영<br>- **Mobile 하단 네비게이션**: 4개 메뉴 → 3개 메뉴로 수정 (홈/회의록/Todo)<br> - 프로필 메뉴 제거 (Desktop 사이드바의 사용자 정보 영역으로 통합)<br> - 프로토타입 실제 구현 상태 반영 (02-대시보드.html, 09-Todo관리.html, 12-회의록목록조회.html)<br> - 사용 화면 번호 업데이트: 08→09, 11→12<br>- **참고 사항**: 프로필 메뉴가 필요한 경우 프로토타입에 추가 구현 필요<br>- **설계서-프로토타입 일관성**: 네비게이션 구조 완전 통일 달성 |
| 1.4.5 | 2025-10-23 | 강지수 | 로그아웃 기능 추가 (Desktop 사이드바 + Mobile 헤더)<br>- **Desktop 좌측 사이드바**: 하단에 사용자 정보 영역 추가<br> - 사용자 정보 (아바타 + 이름 + 이메일)<br> - 로그아웃 버튼 (btn-ghost btn-sm)<br>- **Mobile 상단 헤더**: 우측에 프로필 아이콘 버튼 추가 (👤)<br> - 클릭 시 드롭다운 메뉴 표시 (사용자 정보 + 로그아웃 버튼)<br> - 드롭다운 위치: 우측 상단 기준 아래로 펼침<br> - 오버레이 배경으로 UX 개선<br>- **프로토타입 파일**: 02-대시보드.html, 09-Todo관리.html, 12-회의록목록조회.html<br>- **JavaScript 함수**: toggleProfileMenu(), logout() 추가<br>- **반응형 처리**: Desktop에서는 드롭다운 숨김, Mobile에서는 사이드바 사용자 영역 숨김<br>- **설계서-프로토타입 일관성**: 로그아웃 기능 완전 통일 | | 1.4.5 | 2025-10-23 | 강지수 | 로그아웃 기능 추가 (Desktop 사이드바 + Mobile 헤더)<br>- **Desktop 좌측 사이드바**: 하단에 사용자 정보 영역 추가<br> - 사용자 정보 (아바타 + 이름 + 이메일)<br> - 로그아웃 버튼 (btn-ghost btn-sm)<br>- **Mobile 상단 헤더**: 우측에 프로필 아이콘 버튼 추가 (👤)<br> - 클릭 시 드롭다운 메뉴 표시 (사용자 정보 + 로그아웃 버튼)<br> - 드롭다운 위치: 우측 상단 기준 아래로 펼침<br> - 오버레이 배경으로 UX 개선<br>- **프로토타입 파일**: 02-대시보드.html, 09-Todo관리.html, 12-회의록목록조회.html<br>- **JavaScript 함수**: toggleProfileMenu(), logout() 추가<br>- **반응형 처리**: Desktop에서는 드롭다운 숨김, Mobile에서는 사이드바 사용자 영역 숨김<br>- **설계서-프로토타입 일관성**: 로그아웃 기능 완전 통일 |

View File

@ -1,6 +1,6 @@
# AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.0.1) # AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.0.2)
- [AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.0.1)](#ai기반-회의록-작성-및-이력-관리-개선-서비스---유저스토리-v201) - [AI기반 회의록 작성 및 이력 관리 개선 서비스 - 유저스토리 (v2.0.2)](#ai기반-회의록-작성-및-이력-관리-개선-서비스---유저스토리-v202)
- [차별화 전략](#차별화-전략) - [차별화 전략](#차별화-전략)
- [1. 기본 기능 (Hygiene Factors)](#1-기본-기능-hygiene-factors) - [1. 기본 기능 (Hygiene Factors)](#1-기본-기능-hygiene-factors)
- [2. 핵심 차별화 포인트 (Differentiators)](#2-핵심-차별화-포인트-differentiators) - [2. 핵심 차별화 포인트 (Differentiators)](#2-핵심-차별화-포인트-differentiators)
@ -885,6 +885,67 @@ UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo
--- ---
UFR-TODO-040: [Todo수정] Todo 담당자 또는 회의 생성자로서 | 나는, Todo 내용을 변경하기 위해 | 회의록 확정 전후에 Todo를 수정하고 싶다.
- 시나리오: Todo 수정
Todo 목록에서 수정이 필요한 상황에서 | 담당자 또는 회의 생성자가 Todo 수정 버튼을 클릭하면 | Todo 내용, 담당자, 마감일, 우선순위를 변경할 수 있다.
[화면 정보]
- 화면번호: 09-Todo관리
- 프로토타입: design/uiux/prototype/09-Todo관리.html
[수정 가능 항목]
- Todo 제목 (문구)
- 담당자 (드롭다운 선택)
- 마감일 (날짜 선택기)
- 우선순위 (high/medium/low)
[수정 시점]
- **회의록 확정 전**: 회의 진행 중(05-회의진행) 또는 회의 종료 전(07-회의종료)에서 수정 가능
- **회의록 확정 후**: Todo 관리 화면(09-Todo관리) 또는 회의록 수정 화면(11-회의록수정)에서 수정 가능
[권한 제어]
- **Todo 담당자**: 본인에게 할당된 Todo만 수정 가능 (09-Todo관리)
- 수정 가능 항목: 제목, 마감일, 우선순위
- 담당자 변경 불가 (본인 담당 Todo)
- **회의 생성자**: 해당 회의의 모든 Todo 수정 가능 (11-회의록수정)
- 수정 가능 항목: 제목, 담당자, 마감일, 우선순위
- 담당자 변경 가능
[수정 인터페이스]
- 09-Todo관리: 각 Todo 항목에 "편집" 버튼 표시
- 클릭 시 인라인 편집 모드 또는 수정 모달 표시
- 수정 완료 후 "저장" 버튼 클릭
- 11-회의록수정: "액션 아이템(Todo)" 섹션에서 수정
- 회의록 수정 시 Todo 섹션도 편집 가능
- Todo 추가/삭제/수정 모두 가능
[처리 결과]
- Todo 정보 업데이트
- 수정 시간 기록
- 수정자 정보 저장
- 회의록에 수정 내용 실시간 반영
- 담당자 변경 시 이전 담당자와 새 담당자에게 알림 발송
- 마감일 변경 시 캘린더 자동 업데이트
[알림 발송]
- 담당자 변경 시: 이전 담당자 및 새 담당자에게 알림
- 마감일 변경 시: 담당자에게 알림 (캘린더 업데이트)
- 제목/우선순위 변경 시: 담당자에게 알림 (변경 사항 안내)
[Policy/Rule]
- 담당자는 본인 Todo만 수정 가능 (담당자 변경 불가)
- 회의 생성자는 모든 Todo 수정 가능 (담당자 변경 가능)
- 확정 전/후 모두 수정 가능
- 수정 시 회의록에 즉시 반영
[비고]
- 회의록 확정 후에도 유연한 Todo 관리 가능
- 인사 이동, 우선순위 변경, 일정 조정 등 실무 요구사항 반영
- M/13
---
## 논리 아키텍처 반영 사항 요약 ## 논리 아키텍처 반영 사항 요약
### 1. 마이크로서비스 구성 변경 (v2.0) ### 1. 마이크로서비스 구성 변경 (v2.0)
@ -976,5 +1037,6 @@ UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo
| 1.0 | 2025-01-20 | 도그냥 (서비스 기획자) | 초안 작성 (8개 마이크로서비스) | | 1.0 | 2025-01-20 | 도그냥 (서비스 기획자) | 초안 작성 (8개 마이크로서비스) |
| 2.0 | 2025-01-22 | 길동 (아키텍트) | 논리 아키텍처 반영 (5개 마이크로서비스로 단순화) | | 2.0 | 2025-01-22 | 길동 (아키텍트) | 논리 아키텍처 반영 (5개 마이크로서비스로 단순화) |
| 2.0.1 | 2025-10-23 | 강지수 (Product Designer) | 공유 기능 제거 반영 <br>- AFR-USER-020: 대시보드 "공유받은 회의록" 섹션 제거<br>- UFR-MEET-046: 회의록 목록 카테고리 필터 "공유받은 회의" 제거 | | 2.0.1 | 2025-10-23 | 강지수 (Product Designer) | 공유 기능 제거 반영 <br>- AFR-USER-020: 대시보드 "공유받은 회의록" 섹션 제거<br>- UFR-MEET-046: 회의록 목록 카테고리 필터 "공유받은 회의" 제거 |
| 2.0.2 | 2025-10-23 | 강지수, 도그냥 | Todo 수정 기능 추가 (UFR-TODO-040)<br>- 회의록 확정 전/후 Todo 수정 기능 추가<br>- 권한별 수정 범위: 담당자(본인 Todo만), 회의 생성자(모든 Todo)<br>- 수정 항목: 제목, 담당자, 마감일, 우선순위<br>- 09-Todo관리, 11-회의록수정 화면에서 수정 가능 |
--- ---

View File

@ -0,0 +1,623 @@
# Notification Service - 백엔드 개발 결과서
## 1. 개요
### 1.1 서비스 설명
- **서비스명**: Notification Service
- **목적**: 회의 초대 및 Todo 할당 알림 발송 서비스
- **아키텍처 패턴**: Layered Architecture
- **주요 기능**:
- Azure Event Hubs를 통한 이벤트 기반 알림 발송
- 이메일 템플릿 기반 알림 생성
- 사용자별 알림 설정 관리
- 재시도 메커니즘 (Exponential Backoff)
- 중복 발송 방지 (Idempotency)
### 1.2 개발 기간
- **시작일**: 2025-10-23
- **완료일**: 2025-10-23
- **개발자**: 준호 (Backend Developer)
---
## 2. 기술 스택
### 2.1 프레임워크 및 라이브러리
- **Spring Boot**: 3.3.0
- **Java**: 21
- **Spring Data JPA**: 3.3.0
- **Spring Security**: 6.3.0
- **Spring Mail**: 3.3.0
- **Spring Retry**: 2.0.6
- **Thymeleaf**: 3.1.2
- **Azure Event Hubs**: 5.18.4
- **Azure Storage Blob**: 12.26.1
- **PostgreSQL Driver**: 42.7.3
- **SpringDoc OpenAPI**: 2.5.0
- **Lombok**: 1.18.32
### 2.2 데이터베이스
- **DBMS**: PostgreSQL 16.4
- **Host**: 4.230.159.143:5432
- **Database**: notificationdb
- **User**: hgzerouser
### 2.3 메시지 큐
- **Azure Event Hubs**: 이벤트 기반 알림 처리
- **Consumer Group**: notification-service
- **Checkpoint Store**: Azure Blob Storage
### 2.4 이메일 발송
- **SMTP**: Gmail SMTP 또는 사용자 정의 SMTP
- **Port**: 587 (TLS)
- **Template Engine**: Thymeleaf
---
## 3. 구현 내용
### 3.1 패키지 구조
```
notification/
└── src/main/java/com/unicorn/hgzero/notification/
├── NotificationApplication.java # Spring Boot 메인 클래스
├── domain/ # Domain Layer
│ ├── Notification.java # 알림 Entity
│ ├── NotificationRecipient.java # 수신자 Entity
│ └── NotificationSetting.java # 알림 설정 Entity
├── repository/ # Data Access Layer
│ ├── NotificationRepository.java
│ ├── NotificationRecipientRepository.java
│ └── NotificationSettingRepository.java
├── service/ # Business Logic Layer
│ ├── NotificationService.java # 알림 비즈니스 로직
│ ├── EmailTemplateService.java # 템플릿 렌더링
│ └── EmailClient.java # 이메일 발송
├── controller/ # Presentation Layer
│ ├── NotificationController.java # 알림 조회 API
│ └── NotificationSettingsController.java # 설정 API
├── event/ # Event Handler Layer
│ ├── EventHandler.java # Event Hub 핸들러
│ ├── event/
│ │ ├── MeetingCreatedEvent.java # 회의 생성 이벤트 DTO
│ │ └── TodoAssignedEvent.java # Todo 할당 이벤트 DTO
│ └── processor/
│ └── EventProcessorService.java # Processor 라이프사이클
├── dto/ # Data Transfer Objects
│ ├── request/
│ │ └── UpdateSettingsRequest.java
│ └── response/
│ ├── NotificationResponse.java
│ ├── NotificationListResponse.java
│ └── SettingsResponse.java
├── config/ # Configuration Layer
│ ├── EventHubConfig.java # Event Hub 설정
│ ├── BlobStorageConfig.java # Blob Storage 설정
│ ├── RetryConfig.java # 재시도 정책
│ ├── SecurityConfig.java # Spring Security
│ ├── SwaggerConfig.java # Swagger 설정
│ └── EmailConfig.java # Email 설정
└── exception/ # Exception Handling
└── (향후 추가 예정)
```
### 3.2 구현된 주요 클래스
#### 3.2.1 Domain Layer (Entity)
**Notification.java** (notification/src/main/java/com/unicorn/hgzero/notification/domain/Notification.java:1)
- 알림 기본 정보 관리
- 수신자 목록 (OneToMany)
- 중복 방지를 위한 eventId (unique)
- 상태 추적 (PENDING, PROCESSING, SENT, FAILED, PARTIAL)
**NotificationRecipient.java** (notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationRecipient.java:1)
- 수신자별 발송 상태 추적
- 재시도 로직 지원 (retryCount, nextRetryAt)
- 발송 성공/실패 처리 메서드
**NotificationSetting.java** (notification/src/main/java/com/unicorn/hgzero/notification/domain/NotificationSetting.java:1)
- 사용자별 알림 설정
- 채널 활성화 (email, SMS, push)
- 알림 유형별 활성화
- 방해 금지 시간대 (DND)
#### 3.2.2 Repository Layer
**NotificationRepository.java** (notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRepository.java:1)
- JpaRepository 확장
- 커스텀 쿼리 메서드:
- findByEventId() - 중복 체크
- findByReferenceIdAndReferenceType() - 참조 조회
- findByStatusIn() - 배치 처리
- countByStatusAndCreatedAtBetween() - 통계
**NotificationRecipientRepository.java** (notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationRecipientRepository.java:1)
- 수신자 관리
- 커스텀 쿼리 메서드:
- findByNotificationId() - 알림별 수신자
- findByStatusAndNextRetryAtBefore() - 재시도 대상
- findByRecipientEmail() - 사용자 히스토리
**NotificationSettingRepository.java** (notification/src/main/java/com/unicorn/hgzero/notification/repository/NotificationSettingRepository.java:1)
- 알림 설정 관리
- 커스텀 쿼리 메서드:
- findByUserId() - 사용자 설정 조회
- findByEmailEnabledAndInvitationEnabled() - 발송 대상 필터링
#### 3.2.3 Service Layer
**NotificationService.java** (notification/src/main/java/com/unicorn/hgzero/notification/service/NotificationService.java:1)
- 핵심 비즈니스 로직
- 주요 메서드:
- sendMeetingInvitation() - 회의 초대 알림 발송
- sendTodoAssignment() - Todo 할당 알림 발송
- canSendNotification() - 알림 발송 가능 여부 확인
- 기능:
- 이벤트 중복 체크 (eventId)
- 사용자 알림 설정 확인
- 템플릿 렌더링 및 이메일 발송
- 수신자별 상태 추적
**EmailTemplateService.java** (notification/src/main/java/com/unicorn/hgzero/notification/service/EmailTemplateService.java:1)
- Thymeleaf 템플릿 렌더링
- 주요 메서드:
- renderMeetingInvitation() - 회의 초대 템플릿
- renderTodoAssigned() - Todo 할당 템플릿
- renderReminder() - 리마인더 템플릿
**EmailClient.java** (notification/src/main/java/com/unicorn/hgzero/notification/service/EmailClient.java:1)
- SMTP 이메일 발송
- 재시도 메커니즘 (@Retryable)
- Exponential Backoff (5분 → 15분 → 30분)
- HTML 및 텍스트 이메일 지원
#### 3.2.4 Controller Layer
**NotificationController.java** (notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationController.java:1)
- 알림 조회 API
- 엔드포인트:
- GET /notifications - 알림 목록 조회
- GET /notifications/{id} - 특정 알림 조회
- GET /notifications/statistics - 통계 조회
**NotificationSettingsController.java** (notification/src/main/java/com/unicorn/hgzero/notification/controller/NotificationSettingsController.java:1)
- 알림 설정 API
- 엔드포인트:
- GET /notifications/settings - 설정 조회
- PUT /notifications/settings - 설정 업데이트
#### 3.2.5 Event Handler Layer
**EventHandler.java** (notification/src/main/java/com/unicorn/hgzero/notification/event/EventHandler.java:1)
- Consumer<EventContext> 구현
- 이벤트 수신 및 처리
- 토픽별 라우팅 (meeting, todo)
- 이벤트 유형별 처리 (MeetingCreated, TodoAssigned)
- Checkpoint 업데이트
**EventProcessorService.java** (notification/src/main/java/com/unicorn/hgzero/notification/event/processor/EventProcessorService.java:1)
- EventProcessorClient 라이프사이클 관리
- @PostConstruct - 시작
- @PreDestroy - 종료
- @Retryable - 재시도 지원
#### 3.2.6 Config Layer
**EventHubConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/EventHubConfig.java:1)
- EventProcessorClient Bean 생성
- BlobCheckpointStore 설정
- 오류 핸들러 등록
**BlobStorageConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/BlobStorageConfig.java:1)
- Blob Container Async Client
- Checkpoint 저장소 연결
**RetryConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/RetryConfig.java:1)
- @EnableRetry
- RetryTemplate 구성
- Exponential Backoff Policy
**SecurityConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/SecurityConfig.java:1)
- Spring Security 설정
- CORS 설정
- Stateless 세션 관리
**SwaggerConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/SwaggerConfig.java:1)
- OpenAPI 3.0 설정
- Swagger UI 구성
**EmailConfig.java** (notification/src/main/java/com/unicorn/hgzero/notification/config/EmailConfig.java:1)
- JavaMailSender Bean
- SMTP 설정
---
## 4. 데이터베이스 설계
### 4.1 테이블 구조
#### notifications (알림 테이블)
```sql
CREATE TABLE notifications (
notification_id VARCHAR(36) PRIMARY KEY,
event_id VARCHAR(100) UNIQUE NOT NULL,
reference_id VARCHAR(36) NOT NULL,
reference_type VARCHAR(20) NOT NULL,
notification_type VARCHAR(30) NOT NULL,
title VARCHAR(500) NOT NULL,
message TEXT,
status VARCHAR(20) NOT NULL,
channel VARCHAR(20) NOT NULL,
sent_count INTEGER NOT NULL DEFAULT 0,
failed_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
sent_at TIMESTAMP
);
CREATE INDEX idx_notification_reference ON notifications(reference_id, reference_type);
CREATE INDEX idx_notification_created_at ON notifications(created_at);
```
#### notification_recipients (수신자 테이블)
```sql
CREATE TABLE notification_recipients (
recipient_id VARCHAR(36) PRIMARY KEY,
notification_id VARCHAR(36) NOT NULL,
recipient_user_id VARCHAR(100) NOT NULL,
recipient_name VARCHAR(200) NOT NULL,
recipient_email VARCHAR(320) NOT NULL,
status VARCHAR(20) NOT NULL,
retry_count INTEGER NOT NULL DEFAULT 0,
sent_at TIMESTAMP,
error_message VARCHAR(1000),
next_retry_at TIMESTAMP,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP,
FOREIGN KEY (notification_id) REFERENCES notifications(notification_id)
);
CREATE INDEX idx_recipient_notification ON notification_recipients(notification_id);
CREATE INDEX idx_recipient_email ON notification_recipients(recipient_email);
CREATE INDEX idx_recipient_status ON notification_recipients(status);
```
#### notification_settings (알림 설정 테이블)
```sql
CREATE TABLE notification_settings (
user_id VARCHAR(100) PRIMARY KEY,
email_enabled BOOLEAN NOT NULL DEFAULT TRUE,
sms_enabled BOOLEAN NOT NULL DEFAULT FALSE,
push_enabled BOOLEAN NOT NULL DEFAULT FALSE,
invitation_enabled BOOLEAN NOT NULL DEFAULT TRUE,
todo_assigned_enabled BOOLEAN NOT NULL DEFAULT TRUE,
todo_reminder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
meeting_reminder_enabled BOOLEAN NOT NULL DEFAULT TRUE,
minutes_updated_enabled BOOLEAN NOT NULL DEFAULT TRUE,
todo_completed_enabled BOOLEAN NOT NULL DEFAULT FALSE,
dnd_enabled BOOLEAN NOT NULL DEFAULT FALSE,
dnd_start_time TIME,
dnd_end_time TIME,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP
);
CREATE UNIQUE INDEX idx_setting_user_id ON notification_settings(user_id);
```
---
## 5. API 명세
### 5.1 알림 조회 API
#### 알림 목록 조회
- **URL**: GET /notifications
- **Query Parameters**:
- referenceType: MEETING | TODO (optional)
- notificationType: INVITATION | TODO_ASSIGNED | ... (optional)
- status: PENDING | SENT | FAILED | PARTIAL (optional)
- startDate: ISO DateTime (optional)
- endDate: ISO DateTime (optional)
- **Response**: List<NotificationListResponse>
#### 알림 상세 조회
- **URL**: GET /notifications/{notificationId}
- **Path Parameter**: notificationId (String)
- **Response**: NotificationResponse
#### 알림 통계 조회
- **URL**: GET /notifications/statistics
- **Query Parameters**:
- startDate: ISO DateTime (required)
- endDate: ISO DateTime (required)
- **Response**: { sent, failed, partial, total }
### 5.2 알림 설정 API
#### 알림 설정 조회
- **URL**: GET /notifications/settings
- **Query Parameter**: userId (String, required)
- **Response**: SettingsResponse
#### 알림 설정 업데이트
- **URL**: PUT /notifications/settings
- **Query Parameter**: userId (String, required)
- **Request Body**: UpdateSettingsRequest
- **Response**: SettingsResponse
---
## 6. 이벤트 처리 흐름
### 6.1 회의 초대 알림 발송
```
1. Meeting Service → Event Hub (MeetingCreatedEvent)
2. Event Hub → NotificationService (EventHandler.accept())
3. EventHandler → NotificationService.sendMeetingInvitation()
4. NotificationService:
- 중복 체크 (eventId)
- Notification 엔티티 생성
- 각 참석자별:
- 알림 설정 확인
- NotificationRecipient 생성
- EmailTemplateService.renderMeetingInvitation()
- EmailClient.sendHtmlEmail()
- 상태 업데이트 (SENT/FAILED)
- Notification 상태 업데이트 (SENT/PARTIAL/FAILED)
5. EventHandler → eventContext.updateCheckpoint()
```
### 6.2 Todo 할당 알림 발송
```
1. Meeting/AI Service → Event Hub (TodoAssignedEvent)
2. Event Hub → NotificationService (EventHandler.accept())
3. EventHandler → NotificationService.sendTodoAssignment()
4. NotificationService:
- 중복 체크 (eventId)
- Notification 엔티티 생성
- 알림 설정 확인 (assignee)
- NotificationRecipient 생성
- EmailTemplateService.renderTodoAssigned()
- EmailClient.sendHtmlEmail()
- 상태 업데이트 (SENT/FAILED)
5. EventHandler → eventContext.updateCheckpoint()
```
---
## 7. 재시도 메커니즘
### 7.1 재시도 정책
- **최대 재시도 횟수**: 3회
- **Backoff 전략**: Exponential Backoff
- **초기 대기 시간**: 5분 (300,000ms)
- **최대 대기 시간**: 30분 (1,800,000ms)
- **배수**: 2.0
### 7.2 재시도 시나리오
1. **이메일 발송 실패 시**:
- EmailClient의 @Retryable 어노테이션 적용
- 1차 실패 → 5분 후 재시도
- 2차 실패 → 10분 후 재시도
- 3차 실패 → 20분 후 재시도
- 최종 실패 → NotificationRecipient 상태를 FAILED로 변경
2. **Event Processor 시작 실패 시**:
- EventProcessorService의 @Retryable 적용
- 최대 3번 재시도 (2초, 4초, 8초 대기)
---
## 8. 설정 파일
### 8.1 application.yml
- **위치**: notification/src/main/resources/application.yml
- **주요 설정**:
- 데이터베이스 연결 (PostgreSQL)
- Azure Event Hubs 연결
- Azure Blob Storage 연결
- SMTP 설정
- Thymeleaf 템플릿
- Actuator
- Logging
- SpringDoc OpenAPI
### 8.2 환경 변수 (필수)
```bash
# 데이터베이스
DB_PASSWORD=<PostgreSQL 비밀번호>
# Azure Event Hub
AZURE_EVENTHUB_CONNECTION_STRING=<Event Hub 연결 문자열>
# Azure Blob Storage
AZURE_STORAGE_CONNECTION_STRING=<Blob Storage 연결 문자열>
# 이메일
MAIL_USERNAME=<SMTP 사용자명>
MAIL_PASSWORD=<SMTP 비밀번호>
# JWT (향후 추가)
JWT_SECRET=<JWT 비밀 >
```
---
## 9. 빌드 및 실행
### 9.1 빌드 방법
#### IntelliJ IDEA 사용
1. IntelliJ에서 프로젝트 열기
2. Gradle 탭에서 notification → Tasks → build → build 더블클릭
3. 또는 우측 상단 Build → Build Project
#### 커맨드 라인 (Gradle 설치 필요)
```bash
# Gradle Wrapper 생성 (프로젝트 루트에서)
gradle wrapper
# 빌드
./gradlew :notification:build
# 빌드 결과 확인
ls notification/build/libs/
```
### 9.2 실행 방법
#### IntelliJ IDEA 사용
1. NotificationApplication.java 파일 열기
2. main() 메서드 좌측의 실행 버튼 클릭
3. 또는 Run → Run 'NotificationApplication'
#### JAR 파일 실행
```bash
java -jar notification/build/libs/notification.jar
```
### 9.3 서버 포트
- **기본 포트**: 8084
- **변경 방법**: 환경 변수 SERVER_PORT 설정
---
## 10. 테스트
### 10.1 API 테스트 (Swagger UI)
1. 서버 실행 후 브라우저에서 접속:
```
http://localhost:8084/swagger-ui.html
```
2. API 엔드포인트 테스트:
- GET /notifications - 알림 목록 조회
- GET /notifications/{id} - 특정 알림 조회
- GET /notifications/settings - 알림 설정 조회
- PUT /notifications/settings - 알림 설정 업데이트
### 10.2 이벤트 발행 테스트
1. Meeting Service에서 MeetingCreatedEvent 발행
2. Notification Service 로그 확인:
```
이벤트 수신 - Topic: meeting, EventType: MeetingCreated
회의 초대 알림 처리 시작 - MeetingId: xxx, EventId: xxx
회의 초대 알림 발송 성공 - Email: xxx@xxx.com
```
### 10.3 Health Check
```bash
curl http://localhost:8084/actuator/health
```
---
## 11. 향후 개선 사항
### 11.1 기능 개선
1. **SMS 발송 지원**: 현재 이메일만 지원, SMS 발송 기능 추가
2. **Push 알림 지원**: 모바일 Push 알림 기능 추가
3. **리마인더 스케줄링**: 회의 및 Todo 리마인더 자동 발송
4. **배치 알림 발송**: 대량 알림 발송 최적화
5. **알림 템플릿 관리**: 템플릿 동적 관리 및 다국어 지원
### 11.2 성능 개선
1. **비동기 이메일 발송**: @Async를 사용한 비동기 처리
2. **캐싱 적용**: 알림 설정 캐싱으로 DB 부하 감소
3. **배치 처리**: 대량 수신자 알림의 배치 처리
4. **Connection Pool 최적화**: HikariCP 설정 최적화
### 11.3 모니터링 개선
1. **메트릭 수집**: Prometheus 메트릭 추가
2. **로그 집계**: ELK Stack 연동
3. **알림 대시보드**: Grafana 대시보드 구성
4. **알림 실패 알람**: 발송 실패 시 관리자 알림
---
## 12. 문제 해결 가이드
### 12.1 이메일 발송 실패
**증상**: 이메일이 발송되지 않음
**원인 및 해결방법**:
1. SMTP 인증 정보 확인:
```bash
MAIL_USERNAME=<이메일 주소>
MAIL_PASSWORD=< 비밀번호>
```
2. Gmail 사용 시 앱 비밀번호 생성:
- Google 계정 → 보안 → 2단계 인증 활성화
- 앱 비밀번호 생성
3. 방화벽 확인:
- 포트 587 (TLS) 또는 465 (SSL) 개방 확인
### 12.2 Event Hub 연결 실패
**증상**: 이벤트를 수신하지 못함
**원인 및 해결방법**:
1. 연결 문자열 확인:
```bash
AZURE_EVENTHUB_CONNECTION_STRING=<연결 문자열>
```
2. Consumer Group 확인:
```yaml
azure:
eventhub:
consumer-group: notification-service
```
3. Event Hub 방화벽 설정 확인
### 12.3 데이터베이스 연결 실패
**증상**: 애플리케이션 시작 실패
**원인 및 해결방법**:
1. PostgreSQL 서버 상태 확인
2. 연결 정보 확인:
```yaml
datasource:
url: jdbc:postgresql://4.230.159.143:5432/notificationdb
username: hgzerouser
password: ${DB_PASSWORD}
```
3. 방화벽 확인: 포트 5432 개방 확인
---
## 13. 참고 자료
### 13.1 문서
- [백엔드개발가이드](claude/dev-backend.md)
- [패키지구조도](develop/dev/package-structure-notification.md)
- [API설계서](design/backend/api/API설계서.md)
- [데이터베이스설치결과서](develop/database/exec/db-exec-dev.md)
### 13.2 외부 라이브러리
- [Spring Boot Documentation](https://spring.io/projects/spring-boot)
- [Azure Event Hubs Java SDK](https://learn.microsoft.com/en-us/azure/event-hubs/event-hubs-java-get-started-send)
- [Spring Retry](https://github.com/spring-projects/spring-retry)
- [Thymeleaf](https://www.thymeleaf.org/)
---
**작성일**: 2025-10-23
**작성자**: 준호 (Backend Developer)
**버전**: 1.0

View File

@ -0,0 +1,146 @@
# Notification Service - 패키지 구조도
## 아키텍처 패턴
- **Layered Architecture**: 단순하고 명확한 계층 구조
## 패키지 구조
```
notification/
└── src/
└── main/
├── java/
│ └── com/
│ └── unicorn/
│ └── hgzero/
│ └── notification/
│ ├── NotificationApplication.java
│ │
│ ├── domain/ # Domain Layer
│ │ ├── Notification.java # 알림 Entity
│ │ ├── NotificationRecipient.java # 수신자 Entity
│ │ └── NotificationSetting.java # 알림 설정 Entity
│ │
│ ├── repository/ # Data Access Layer
│ │ ├── NotificationRepository.java
│ │ ├── NotificationRecipientRepository.java
│ │ └── NotificationSettingRepository.java
│ │
│ ├── service/ # Business Logic Layer
│ │ ├── NotificationService.java # 알림 비즈니스 로직
│ │ ├── EmailTemplateService.java # 이메일 템플릿 렌더링
│ │ └── EmailClient.java # 이메일 발송 클라이언트
│ │
│ ├── controller/ # Presentation Layer
│ │ ├── NotificationController.java # Public API
│ │ └── NotificationSettingsController.java # 설정 API
│ │
│ ├── event/ # Event Handler Layer
│ │ ├── EventHandler.java # Event Hub 이벤트 핸들러
│ │ ├── event/
│ │ │ ├── MeetingCreatedEvent.java # 회의 생성 이벤트
│ │ │ └── TodoAssignedEvent.java # Todo 할당 이벤트
│ │ └── processor/
│ │ └── EventProcessorService.java # Processor 라이프사이클
│ │
│ ├── dto/ # Data Transfer Objects
│ │ ├── request/
│ │ │ ├── SendNotificationRequest.java
│ │ │ └── UpdateSettingsRequest.java
│ │ └── response/
│ │ ├── NotificationResponse.java
│ │ ├── NotificationListResponse.java
│ │ └── SettingsResponse.java
│ │
│ ├── config/ # Configuration Layer
│ │ ├── EventHubConfig.java # Event Hub 설정
│ │ ├── BlobStorageConfig.java # Blob Storage 설정
│ │ ├── RetryConfig.java # 재시도 정책 설정
│ │ ├── SecurityConfig.java # Spring Security 설정
│ │ ├── SwaggerConfig.java # Swagger 설정
│ │ └── EmailConfig.java # Email 설정
│ │
│ └── exception/ # Exception Handling
│ ├── GlobalExceptionHandler.java
│ ├── NotificationException.java
│ └── EventProcessingException.java
└── resources/
├── application.yml # 메인 설정 파일
├── application-dev.yml # 개발 환경 설정
├── application-prod.yml # 운영 환경 설정
└── templates/ # Email Templates
├── meeting-invitation.html # 회의 초대 템플릿
├── todo-assigned.html # Todo 할당 템플릿
└── reminder.html # 리마인더 템플릿
```
## 주요 클래스 역할
### Domain Layer
- **Notification**: 알림 정보 엔티티 (알림ID, 유형, 상태, 발송일시)
- **NotificationRecipient**: 수신자별 알림 상태 (발송완료, 실패, 재시도)
- **NotificationSetting**: 사용자별 알림 설정 (채널, 유형, 방해금지 시간대)
### Repository Layer
- **NotificationRepository**: 알림 이력 조회/저장
- **NotificationRecipientRepository**: 수신자별 상태 관리
- **NotificationSettingRepository**: 알림 설정 관리
### Service Layer
- **NotificationService**: 알림 발송 비즈니스 로직, 중복 방지, 재시도 관리
- **EmailTemplateService**: Thymeleaf 템플릿 렌더링
- **EmailClient**: SMTP 이메일 발송, 에러 처리
### Controller Layer
- **NotificationController**: 알림 발송 API, 알림 이력 조회 API
- **NotificationSettingsController**: 알림 설정 조회/업데이트 API
### Event Handler Layer
- **EventHandler**: Event Hub 이벤트 수신 및 처리 (Consumer<EventContext> 구현)
- **EventProcessorService**: EventProcessorClient 라이프사이클 관리
- **MeetingCreatedEvent**: 회의 생성 이벤트 DTO
- **TodoAssignedEvent**: Todo 할당 이벤트 DTO
### Config Layer
- **EventHubConfig**: EventProcessorClient Bean 생성, CheckpointStore 설정
- **BlobStorageConfig**: Azure Blob Storage 연결 설정
- **RetryConfig**: @EnableRetry, ExponentialBackOffPolicy 설정
- **SecurityConfig**: JWT 인증, CORS 설정
- **SwaggerConfig**: OpenAPI 문서화 설정
- **EmailConfig**: JavaMailSender 설정
## 의존성 흐름
```
Controller → Service → Repository → Entity
EmailClient
EmailTemplateService
EventHandler → Service → Repository
```
## 기술 스택
- **Framework**: Spring Boot 3.3.0, Java 21
- **Database**: PostgreSQL (JPA/Hibernate)
- **Messaging**: Azure Event Hubs
- **Storage**: Azure Blob Storage (Checkpoint)
- **Email**: Spring Mail (SMTP)
- **Template**: Thymeleaf
- **Retry**: Spring Retry
- **Security**: Spring Security + JWT
- **Documentation**: SpringDoc OpenAPI
## 특징
1. **Layered Architecture**: 계층 분리로 명확한 역할과 책임
2. **Event-Driven**: Azure Event Hubs 기반 비동기 처리
3. **Retry Mechanism**: Exponential Backoff 기반 재시도
4. **Template Engine**: Thymeleaf로 동적 이메일 생성
5. **Idempotency**: 이벤트 ID 기반 중복 발송 방지
6. **Monitoring**: Actuator Health Check, Metrics
---
**작성일**: 2025-10-23
**작성자**: 준호 (Backend Developer)

View File

@ -0,0 +1,22 @@
package com.unicorn.hgzero.notification;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Notification Service Application
*
* 회의 초대 Todo 할당 알림 발송 서비스
* Azure Event Hubs를 통한 이벤트 기반 알림 처리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@SpringBootApplication
public class NotificationApplication {
public static void main(String[] args) {
SpringApplication.run(NotificationApplication.class, args);
}
}

View File

@ -0,0 +1,48 @@
package com.unicorn.hgzero.notification.config;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Azure Blob Storage 설정
*
* Event Hub Checkpoint 저장용 Blob Storage 연결 구성
* EventProcessorClient의 체크포인트 저장소로 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
public class BlobStorageConfig {
@Value("${azure.storage.connection-string}")
private String storageConnectionString;
@Value("${azure.storage.container-name}")
private String containerName;
/**
* Blob Container Async Client Bean 생성
*
* @return Blob Container Async Client
*/
@Bean
public BlobContainerAsyncClient blobContainerAsyncClient() {
log.info("BlobContainerAsyncClient 생성 중 - Container: {}", containerName);
BlobContainerAsyncClient client = new BlobContainerClientBuilder()
.connectionString(storageConnectionString)
.containerName(containerName)
.buildAsyncClient();
log.info("BlobContainerAsyncClient 생성 완료");
return client;
}
}

View File

@ -0,0 +1,72 @@
package com.unicorn.hgzero.notification.config;
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.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import java.util.Properties;
/**
* 이메일 발송 설정
*
* JavaMailSender 구성 SMTP 설정
* Gmail SMTP 또는 다른 SMTP 서버 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
public class EmailConfig {
@Value("${spring.mail.host}")
private String host;
@Value("${spring.mail.port}")
private int port;
@Value("${spring.mail.username}")
private String username;
@Value("${spring.mail.password}")
private String password;
@Value("${spring.mail.properties.mail.smtp.auth:true}")
private String smtpAuth;
@Value("${spring.mail.properties.mail.smtp.starttls.enable:true}")
private String starttlsEnable;
/**
* JavaMailSender Bean 생성
*
* @return JavaMailSender
*/
@Bean
public JavaMailSender javaMailSender() {
log.info("JavaMailSender 구성 중 - Host: {}, Port: {}", host, port);
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
// SMTP 서버 설정
mailSender.setHost(host);
mailSender.setPort(port);
mailSender.setUsername(username);
mailSender.setPassword(password);
// SMTP 속성 설정
Properties props = mailSender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", smtpAuth);
props.put("mail.smtp.starttls.enable", starttlsEnable);
props.put("mail.debug", "false"); // 디버그 모드 (필요 true)
log.info("JavaMailSender 구성 완료");
return mailSender;
}
}

View File

@ -0,0 +1,74 @@
package com.unicorn.hgzero.notification.config;
import com.azure.messaging.eventhubs.EventProcessorClient;
import com.azure.messaging.eventhubs.EventProcessorClientBuilder;
import com.azure.messaging.eventhubs.checkpointstore.blob.BlobCheckpointStore;
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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.function.Consumer;
/**
* Azure Event Hubs 설정
*
* EventProcessorClient 구성 이벤트 처리 설정
* Blob Storage 기반 Checkpoint 관리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
public class EventHubConfig {
@Value("${azure.eventhub.connection-string}")
private String eventHubConnectionString;
@Value("${azure.eventhub.name}")
private String eventHubName;
@Value("${azure.eventhub.consumer-group}")
private String consumerGroup;
/**
* EventProcessorClient Bean 생성
*
* @param blobContainerAsyncClient Blob Storage 클라이언트
* @param eventHandler 이벤트 핸들러
* @return EventProcessorClient
*/
@Bean
public EventProcessorClient eventProcessorClient(
BlobContainerAsyncClient blobContainerAsyncClient,
Consumer<EventContext> eventHandler
) {
log.info("EventProcessorClient 생성 중 - EventHub: {}, ConsumerGroup: {}",
eventHubName, consumerGroup);
// Blob Checkpoint Store 생성
BlobCheckpointStore checkpointStore = new BlobCheckpointStore(blobContainerAsyncClient);
// EventProcessorClient 빌더 구성
EventProcessorClient eventProcessorClient = new EventProcessorClientBuilder()
.connectionString(eventHubConnectionString, eventHubName)
.consumerGroup(consumerGroup)
.checkpointStore(checkpointStore)
.processEvent(eventHandler)
.processError(errorContext -> {
log.error("이벤트 처리 오류 발생 - PartitionId: {}, ErrorType: {}",
errorContext.getPartitionContext().getPartitionId(),
errorContext.getThrowable().getClass().getSimpleName(),
errorContext.getThrowable());
})
.buildEventProcessorClient();
log.info("EventProcessorClient 생성 완료");
return eventProcessorClient;
}
}

View File

@ -0,0 +1,56 @@
package com.unicorn.hgzero.notification.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;
/**
* 재시도 정책 설정
*
* Spring Retry를 사용한 재시도 메커니즘 구성
* 이메일 발송 실패 Exponential Backoff 전략 적용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
@EnableRetry
public class RetryConfig {
/**
* RetryTemplate Bean 생성
*
* 재시도 정책:
* - 최대 3번 재시도
* - Exponential Backoff: 초기 5분, 최대 30분, 배수 2.0
*
* @return 구성된 RetryTemplate
*/
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate retryTemplate = new RetryTemplate();
// 재시도 정책: 최대 3번
SimpleRetryPolicy retryPolicy = new SimpleRetryPolicy();
retryPolicy.setMaxAttempts(3);
// Backoff 정책: Exponential Backoff
ExponentialBackOffPolicy backOffPolicy = new ExponentialBackOffPolicy();
backOffPolicy.setInitialInterval(300000); // 5분
backOffPolicy.setMaxInterval(1800000); // 30분
backOffPolicy.setMultiplier(2.0); // 배수
retryTemplate.setRetryPolicy(retryPolicy);
retryTemplate.setBackOffPolicy(backOffPolicy);
log.info("RetryTemplate 생성 완료 - MaxAttempts: 3, InitialInterval: 5분, MaxInterval: 30분, Multiplier: 2.0");
return retryTemplate;
}
}

View File

@ -0,0 +1,113 @@
package com.unicorn.hgzero.notification.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Spring Security 설정
*
* JWT 기반 인증, CORS 설정
* Stateless 세션 관리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
@EnableWebSecurity
public class SecurityConfig {
/**
* Security Filter Chain 구성
*
* @param http HttpSecurity
* @return SecurityFilterChain
* @throws Exception 설정 오류
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
log.info("SecurityFilterChain 구성 중...");
http
// CSRF 비활성화 (REST API이므로)
.csrf(AbstractHttpConfigurer::disable)
// CORS 설정 활성화
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 세션 관리: Stateless (JWT 사용)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 요청 인가 규칙
.authorizeHttpRequests(auth -> auth
// Swagger UI API 문서는 인증 없이 접근 가능
.requestMatchers(
"/swagger-ui/**",
"/v3/api-docs/**",
"/swagger-resources/**",
"/webjars/**"
).permitAll()
// Actuator Health Check는 인증 없이 접근 가능
.requestMatchers("/actuator/health").permitAll()
// 모든 요청은 인증 필요
.anyRequest().authenticated()
);
log.info("SecurityFilterChain 구성 완료");
return http.build();
}
/**
* CORS 설정
*
* @return CORS Configuration Source
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 허용할 Origin (개발 환경)
configuration.setAllowedOrigins(List.of(
"http://localhost:3000",
"http://localhost:8080"
));
// 허용할 HTTP 메서드
configuration.setAllowedMethods(List.of(
"GET", "POST", "PUT", "DELETE", "OPTIONS"
));
// 허용할 헤더
configuration.setAllowedHeaders(List.of("*"));
// 인증 정보 포함 허용
configuration.setAllowCredentials(true);
// 최대 캐시 시간 ()
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
log.info("CORS 설정 완료");
return source;
}
}

View File

@ -0,0 +1,65 @@
package com.unicorn.hgzero.notification.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.servers.Server;
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 java.util.List;
/**
* Swagger/OpenAPI 설정
*
* API 문서 자동 생성 Swagger UI 설정
* SpringDoc OpenAPI 3.0 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Configuration
public class SwaggerConfig {
@Value("${spring.application.name:notification}")
private String applicationName;
/**
* OpenAPI 설정 Bean 생성
*
* @return OpenAPI 설정
*/
@Bean
public OpenAPI openAPI() {
log.info("OpenAPI 설정 생성 중...");
OpenAPI openAPI = new OpenAPI()
.info(new Info()
.title("HGZero Notification Service API")
.description("회의 초대 및 Todo 할당 알림 발송 서비스 API")
.version("1.0.0")
.contact(new Contact()
.name("Backend Team")
.email("backend@hgzero.com"))
.license(new License()
.name("Apache 2.0")
.url("https://www.apache.org/licenses/LICENSE-2.0.html")))
.servers(List.of(
new Server()
.url("http://localhost:8080")
.description("Local Development Server"),
new Server()
.url("https://api.hgzero.com")
.description("Production Server")
));
log.info("OpenAPI 설정 생성 완료");
return openAPI;
}
}

View File

@ -0,0 +1,183 @@
package com.unicorn.hgzero.notification.controller;
import com.unicorn.hgzero.notification.domain.Notification;
import com.unicorn.hgzero.notification.dto.response.NotificationListResponse;
import com.unicorn.hgzero.notification.dto.response.NotificationResponse;
import com.unicorn.hgzero.notification.repository.NotificationRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 알림 조회 API Controller
*
* 알림 이력 조회 API 제공
* 알림 목록 조회, 특정 알림 상세 조회 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@RestController
@RequestMapping("/notifications")
@RequiredArgsConstructor
@Tag(name = "Notification", description = "알림 조회 API")
public class NotificationController {
private final NotificationRepository notificationRepository;
/**
* 알림 목록 조회
*
* @param referenceType 참조 유형 (MEETING, TODO) - optional
* @param notificationType 알림 유형 (INVITATION, TODO_ASSIGNED ) - optional
* @param status 알림 상태 (PENDING, SENT, FAILED ) - optional
* @param startDate 시작 일시 - optional
* @param endDate 종료 일시 - optional
* @return 알림 목록
*/
@Operation(summary = "알림 목록 조회", description = "다양한 조건으로 알림 목록을 조회합니다")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = NotificationListResponse.class))
),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
@GetMapping
public ResponseEntity<List<NotificationListResponse>> getNotifications(
@Parameter(description = "참조 유형 (MEETING, TODO)")
@RequestParam(required = false) Notification.ReferenceType referenceType,
@Parameter(description = "알림 유형 (INVITATION, TODO_ASSIGNED, TODO_REMINDER 등)")
@RequestParam(required = false) Notification.NotificationType notificationType,
@Parameter(description = "알림 상태 (PENDING, PROCESSING, SENT, FAILED, PARTIAL)")
@RequestParam(required = false) Notification.NotificationStatus status,
@Parameter(description = "시작 일시 (yyyy-MM-dd'T'HH:mm:ss)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@Parameter(description = "종료 일시 (yyyy-MM-dd'T'HH:mm:ss)")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate
) {
log.info("알림 목록 조회 - ReferenceType: {}, NotificationType: {}, Status: {}, StartDate: {}, EndDate: {}",
referenceType, notificationType, status, startDate, endDate);
List<Notification> notifications;
// 조건별 조회
if (notificationType != null) {
notifications = notificationRepository.findByNotificationType(notificationType);
} else if (status != null) {
notifications = notificationRepository.findByStatusIn(List.of(status));
} else if (startDate != null && endDate != null) {
notifications = notificationRepository.findByCreatedAtBetween(startDate, endDate);
} else {
// 기본: 모든 알림 조회 (최근 )
notifications = notificationRepository.findAll();
}
List<NotificationListResponse> response = notifications.stream()
.map(NotificationListResponse::from)
.collect(Collectors.toList());
log.info("알림 목록 조회 완료 - 조회 건수: {}", response.size());
return ResponseEntity.ok(response);
}
/**
* 특정 알림 상세 조회
*
* @param notificationId 알림 ID
* @return 알림 상세 정보 (수신자 목록 포함)
*/
@Operation(summary = "알림 상세 조회", description = "특정 알림의 상세 정보를 조회합니다 (수신자 목록 포함)")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = NotificationResponse.class))
),
@ApiResponse(responseCode = "404", description = "알림을 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
@GetMapping("/{notificationId}")
public ResponseEntity<NotificationResponse> getNotification(
@Parameter(description = "알림 ID", required = true)
@PathVariable String notificationId
) {
log.info("알림 상세 조회 - NotificationId: {}", notificationId);
Notification notification = notificationRepository.findById(notificationId)
.orElseThrow(() -> {
log.error("알림을 찾을 수 없음 - NotificationId: {}", notificationId);
return new RuntimeException("알림을 찾을 수 없습니다: " + notificationId);
});
NotificationResponse response = NotificationResponse.from(notification);
log.info("알림 상세 조회 완료 - NotificationId: {}", notificationId);
return ResponseEntity.ok(response);
}
/**
* 알림 상태별 통계 조회 (모니터링용)
*
* @param startDate 시작 일시
* @param endDate 종료 일시
* @return 상태별 알림 건수
*/
@Operation(summary = "알림 통계 조회", description = "기간별 알림 상태 통계를 조회합니다")
@GetMapping("/statistics")
public ResponseEntity<Object> getStatistics(
@Parameter(description = "시작 일시")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startDate,
@Parameter(description = "종료 일시")
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endDate
) {
log.info("알림 통계 조회 - StartDate: {}, EndDate: {}", startDate, endDate);
long sentCount = notificationRepository.countByStatusAndCreatedAtBetween(
Notification.NotificationStatus.SENT, startDate, endDate
);
long failedCount = notificationRepository.countByStatusAndCreatedAtBetween(
Notification.NotificationStatus.FAILED, startDate, endDate
);
long partialCount = notificationRepository.countByStatusAndCreatedAtBetween(
Notification.NotificationStatus.PARTIAL, startDate, endDate
);
var statistics = new Object() {
public final long sent = sentCount;
public final long failed = failedCount;
public final long partial = partialCount;
public final long total = sentCount + failedCount + partialCount;
};
log.info("알림 통계 조회 완료 - Sent: {}, Failed: {}, Partial: {}, Total: {}",
sentCount, failedCount, partialCount, statistics.total);
return ResponseEntity.ok(statistics);
}
}

View File

@ -0,0 +1,137 @@
package com.unicorn.hgzero.notification.controller;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import com.unicorn.hgzero.notification.dto.request.UpdateSettingsRequest;
import com.unicorn.hgzero.notification.dto.response.SettingsResponse;
import com.unicorn.hgzero.notification.repository.NotificationSettingRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 알림 설정 API Controller
*
* 사용자별 알림 설정 조회 업데이트 API 제공
* 채널 활성화, 알림 유형, 방해 금지 시간대 관리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@RestController
@RequestMapping("/notifications/settings")
@RequiredArgsConstructor
@Tag(name = "Notification Settings", description = "알림 설정 API")
public class NotificationSettingsController {
private final NotificationSettingRepository settingRepository;
/**
* 알림 설정 조회
*
* @param userId 사용자 ID
* @return 알림 설정 정보
*/
@Operation(summary = "알림 설정 조회", description = "사용자의 알림 설정을 조회합니다")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "조회 성공",
content = @Content(schema = @Schema(implementation = SettingsResponse.class))
),
@ApiResponse(responseCode = "404", description = "설정을 찾을 수 없음"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
@GetMapping
public ResponseEntity<SettingsResponse> getSettings(
@Parameter(description = "사용자 ID", required = true)
@RequestParam String userId
) {
log.info("알림 설정 조회 - UserId: {}", userId);
NotificationSetting setting = settingRepository.findByUserId(userId)
.orElseGet(() -> {
// 설정이 없으면 기본 설정 생성
log.info("알림 설정이 없어 기본 설정 생성 - UserId: {}", userId);
NotificationSetting defaultSetting = NotificationSetting.builder()
.userId(userId)
.build();
return settingRepository.save(defaultSetting);
});
SettingsResponse response = SettingsResponse.from(setting);
log.info("알림 설정 조회 완료 - UserId: {}", userId);
return ResponseEntity.ok(response);
}
/**
* 알림 설정 업데이트
*
* @param userId 사용자 ID
* @param request 업데이트할 설정 정보
* @return 업데이트된 알림 설정 정보
*/
@Operation(summary = "알림 설정 업데이트", description = "사용자의 알림 설정을 업데이트합니다")
@ApiResponses(value = {
@ApiResponse(
responseCode = "200",
description = "업데이트 성공",
content = @Content(schema = @Schema(implementation = SettingsResponse.class))
),
@ApiResponse(responseCode = "400", description = "잘못된 요청"),
@ApiResponse(responseCode = "500", description = "서버 오류")
})
@PutMapping
public ResponseEntity<SettingsResponse> updateSettings(
@Parameter(description = "사용자 ID", required = true)
@RequestParam String userId,
@Parameter(description = "업데이트할 설정 정보", required = true)
@Valid @RequestBody UpdateSettingsRequest request
) {
log.info("알림 설정 업데이트 - UserId: {}", userId);
NotificationSetting setting = settingRepository.findByUserId(userId)
.orElseGet(() -> {
// 설정이 없으면 새로 생성
log.info("알림 설정이 없어 신규 생성 - UserId: {}", userId);
NotificationSetting newSetting = NotificationSetting.builder()
.userId(userId)
.build();
return newSetting;
});
// 설정 업데이트
setting.setEmailEnabled(request.getEmailEnabled());
setting.setSmsEnabled(request.getSmsEnabled());
setting.setPushEnabled(request.getPushEnabled());
setting.setInvitationEnabled(request.getInvitationEnabled());
setting.setTodoAssignedEnabled(request.getTodoAssignedEnabled());
setting.setTodoReminderEnabled(request.getTodoReminderEnabled());
setting.setMeetingReminderEnabled(request.getMeetingReminderEnabled());
setting.setMinutesUpdatedEnabled(request.getMinutesUpdatedEnabled());
setting.setTodoCompletedEnabled(request.getTodoCompletedEnabled());
setting.setDndEnabled(request.getDndEnabled());
setting.setDndStartTime(request.getDndStartTime());
setting.setDndEndTime(request.getDndEndTime());
NotificationSetting savedSetting = settingRepository.save(setting);
SettingsResponse response = SettingsResponse.from(savedSetting);
log.info("알림 설정 업데이트 완료 - UserId: {}", userId);
return ResponseEntity.ok(response);
}
}

View File

@ -0,0 +1,248 @@
package com.unicorn.hgzero.notification.domain;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* 알림 Entity
*
* 알림 발송 이력을 관리하는 엔티티
* 회의 초대, Todo 할당 다양한 알림 유형을 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Entity
@Table(name = "notifications", indexes = {
@Index(name = "idx_notification_reference", columnList = "reference_id, reference_type"),
@Index(name = "idx_notification_created_at", columnList = "created_at")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Comment("알림 정보")
public class Notification {
/**
* 알림 고유 ID (UUID)
*/
@Id
@Column(name = "notification_id", length = 36, nullable = false)
@Comment("알림 고유 ID")
private String notificationId;
/**
* 이벤트 고유 ID (중복 발송 방지용)
*/
@Column(name = "event_id", length = 100, nullable = false, unique = true)
@Comment("이벤트 고유 ID (Idempotency)")
private String eventId;
/**
* 참조 대상 ID (meetingId 또는 todoId)
*/
@Column(name = "reference_id", length = 36, nullable = false)
@Comment("참조 대상 ID (회의 또는 Todo)")
private String referenceId;
/**
* 참조 유형 (MEETING, TODO)
*/
@Enumerated(EnumType.STRING)
@Column(name = "reference_type", length = 20, nullable = false)
@Comment("참조 유형")
private ReferenceType referenceType;
/**
* 알림 유형 (INVITATION, TODO_ASSIGNED, REMINDER)
*/
@Enumerated(EnumType.STRING)
@Column(name = "notification_type", length = 30, nullable = false)
@Comment("알림 유형")
private NotificationType notificationType;
/**
* 알림 제목
*/
@Column(name = "title", length = 500, nullable = false)
@Comment("알림 제목")
private String title;
/**
* 알림 내용 (간단한 요약)
*/
@Column(name = "message", columnDefinition = "TEXT")
@Comment("알림 내용")
private String message;
/**
* 알림 상태 (PENDING, PROCESSING, SENT, FAILED, PARTIAL)
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
@Comment("알림 상태")
private NotificationStatus status;
/**
* 발송 채널 (EMAIL, SMS, PUSH)
*/
@Enumerated(EnumType.STRING)
@Column(name = "channel", length = 20, nullable = false)
@Comment("발송 채널")
private NotificationChannel channel;
/**
* 발송 완료 건수
*/
@Column(name = "sent_count", nullable = false)
@Comment("발송 완료 건수")
@Builder.Default
private Integer sentCount = 0;
/**
* 발송 실패 건수
*/
@Column(name = "failed_count", nullable = false)
@Comment("발송 실패 건수")
@Builder.Default
private Integer failedCount = 0;
/**
* 생성 일시
*/
@Column(name = "created_at", nullable = false, updatable = false)
@Comment("생성 일시")
private LocalDateTime createdAt;
/**
* 발송 완료 일시
*/
@Column(name = "sent_at")
@Comment("발송 완료 일시")
private LocalDateTime sentAt;
/**
* 수신자 목록
*
* Cascade: ALL - 알림 삭제 수신자 정보도 함께 삭제
* Orphan Removal: true - 수신자 목록에서 제거 DB에서도 삭제
*/
@OneToMany(mappedBy = "notification", cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<NotificationRecipient> recipients = new ArrayList<>();
/**
* 생성 초기화
*
* - notificationId: UUID 생성
* - createdAt: 현재 시각
* - status: PENDING
*/
@PrePersist
protected void onCreate() {
if (this.notificationId == null) {
this.notificationId = UUID.randomUUID().toString();
}
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
if (this.status == null) {
this.status = NotificationStatus.PENDING;
}
}
/**
* 수신자 추가 헬퍼 메서드
*
* @param recipient 수신자 정보
*/
public void addRecipient(NotificationRecipient recipient) {
recipients.add(recipient);
recipient.setNotification(this);
}
/**
* 수신자 제거 헬퍼 메서드
*
* @param recipient 수신자 정보
*/
public void removeRecipient(NotificationRecipient recipient) {
recipients.remove(recipient);
recipient.setNotification(null);
}
/**
* 알림 상태 업데이트
*
* @param status 새로운 상태
*/
public void updateStatus(NotificationStatus status) {
this.status = status;
if (status == NotificationStatus.SENT || status == NotificationStatus.PARTIAL) {
this.sentAt = LocalDateTime.now();
}
}
/**
* 발송 건수 증가
*/
public void incrementSentCount() {
this.sentCount++;
}
/**
* 실패 건수 증가
*/
public void incrementFailedCount() {
this.failedCount++;
}
/**
* 참조 유형 Enum
*/
public enum ReferenceType {
MEETING, // 회의
TODO // Todo
}
/**
* 알림 유형 Enum
*/
public enum NotificationType {
INVITATION, // 회의 초대
TODO_ASSIGNED, // Todo 할당
TODO_REMINDER, // Todo 리마인더
MEETING_REMINDER, // 회의 리마인더
MINUTES_UPDATED, // 회의록 수정
TODO_COMPLETED // Todo 완료
}
/**
* 알림 상태 Enum
*/
public enum NotificationStatus {
PENDING, // 대기
PROCESSING, // 처리
SENT, // 발송 완료
FAILED, // 발송 실패
PARTIAL // 부분 성공
}
/**
* 발송 채널 Enum
*/
public enum NotificationChannel {
EMAIL, // 이메일
SMS, // SMS
PUSH // Push 알림
}
}

View File

@ -0,0 +1,225 @@
package com.unicorn.hgzero.notification.domain;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 알림 수신자 Entity
*
* 알림별 수신자 정보와 발송 상태를 관리
* 수신자별로 발송 성공/실패 상태를 추적
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Entity
@Table(name = "notification_recipients", indexes = {
@Index(name = "idx_recipient_notification", columnList = "notification_id"),
@Index(name = "idx_recipient_email", columnList = "recipient_email"),
@Index(name = "idx_recipient_status", columnList = "status")
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Comment("알림 수신자 정보")
public class NotificationRecipient {
/**
* 수신자 고유 ID (UUID)
*/
@Id
@Column(name = "recipient_id", length = 36, nullable = false)
@Comment("수신자 고유 ID")
private String recipientId;
/**
* 알림 (외래키)
*/
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "notification_id", nullable = false)
@Comment("알림 ID")
private Notification notification;
/**
* 수신자 사용자 ID
*/
@Column(name = "recipient_user_id", length = 100, nullable = false)
@Comment("수신자 사용자 ID")
private String recipientUserId;
/**
* 수신자 이름
*/
@Column(name = "recipient_name", length = 200, nullable = false)
@Comment("수신자 이름")
private String recipientName;
/**
* 수신자 이메일
*/
@Column(name = "recipient_email", length = 320, nullable = false)
@Comment("수신자 이메일")
private String recipientEmail;
/**
* 발송 상태 (PENDING, SENT, FAILED, RETRY)
*/
@Enumerated(EnumType.STRING)
@Column(name = "status", length = 20, nullable = false)
@Comment("발송 상태")
private RecipientStatus status;
/**
* 재시도 횟수
*/
@Column(name = "retry_count", nullable = false)
@Comment("재시도 횟수")
@Builder.Default
private Integer retryCount = 0;
/**
* 발송 일시
*/
@Column(name = "sent_at")
@Comment("발송 일시")
private LocalDateTime sentAt;
/**
* 실패 사유
*/
@Column(name = "error_message", length = 1000)
@Comment("실패 사유")
private String errorMessage;
/**
* 다음 재시도 일시
*/
@Column(name = "next_retry_at")
@Comment("다음 재시도 일시")
private LocalDateTime nextRetryAt;
/**
* 생성 일시
*/
@Column(name = "created_at", nullable = false, updatable = false)
@Comment("생성 일시")
private LocalDateTime createdAt;
/**
* 수정 일시
*/
@Column(name = "updated_at")
@Comment("수정 일시")
private LocalDateTime updatedAt;
/**
* 생성 초기화
*
* - recipientId: UUID 생성
* - createdAt: 현재 시각
* - status: PENDING
* - retryCount: 0
*/
@PrePersist
protected void onCreate() {
if (this.recipientId == null) {
this.recipientId = UUID.randomUUID().toString();
}
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
if (this.status == null) {
this.status = RecipientStatus.PENDING;
}
if (this.retryCount == null) {
this.retryCount = 0;
}
}
/**
* 수정 업데이트
*
* - updatedAt: 현재 시각
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 발송 성공 처리
*/
public void markAsSent() {
this.status = RecipientStatus.SENT;
this.sentAt = LocalDateTime.now();
this.errorMessage = null;
this.nextRetryAt = null;
}
/**
* 발송 실패 처리
*
* @param errorMessage 실패 사유
*/
public void markAsFailed(String errorMessage) {
this.status = RecipientStatus.FAILED;
this.errorMessage = errorMessage;
this.incrementRetryCount();
}
/**
* 재시도 상태로 변경
*
* @param nextRetryAt 다음 재시도 일시
*/
public void markForRetry(LocalDateTime nextRetryAt) {
this.status = RecipientStatus.RETRY;
this.nextRetryAt = nextRetryAt;
this.incrementRetryCount();
}
/**
* 재시도 횟수 증가
*/
private void incrementRetryCount() {
this.retryCount++;
}
/**
* 최대 재시도 횟수 초과 여부 확인
*
* @param maxRetries 최대 재시도 횟수
* @return 초과 여부
*/
public boolean exceedsMaxRetries(int maxRetries) {
return this.retryCount >= maxRetries;
}
/**
* 재시도 가능 여부 확인
*
* @return 재시도 가능 여부
*/
public boolean canRetry() {
return this.status == RecipientStatus.RETRY
&& this.nextRetryAt != null
&& LocalDateTime.now().isAfter(this.nextRetryAt);
}
/**
* 수신자 발송 상태 Enum
*/
public enum RecipientStatus {
PENDING, // 대기
SENT, // 발송 완료
FAILED, // 발송 실패
RETRY // 재시도 예정
}
}

View File

@ -0,0 +1,252 @@
package com.unicorn.hgzero.notification.domain;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 알림 설정 Entity
*
* 사용자별 알림 설정 정보를 관리
* 알림 채널, 유형별 활성화 여부, 방해 금지 시간대 설정
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Entity
@Table(name = "notification_settings", indexes = {
@Index(name = "idx_setting_user_id", columnList = "user_id", unique = true)
})
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Comment("알림 설정 정보")
public class NotificationSetting {
/**
* 설정 고유 ID (사용자 ID와 동일)
*/
@Id
@Column(name = "user_id", length = 100, nullable = false)
@Comment("사용자 ID")
private String userId;
/**
* 이메일 알림 활성화 여부
*/
@Column(name = "email_enabled", nullable = false)
@Comment("이메일 알림 활성화")
@Builder.Default
private Boolean emailEnabled = true;
/**
* SMS 알림 활성화 여부
*/
@Column(name = "sms_enabled", nullable = false)
@Comment("SMS 알림 활성화")
@Builder.Default
private Boolean smsEnabled = false;
/**
* Push 알림 활성화 여부
*/
@Column(name = "push_enabled", nullable = false)
@Comment("Push 알림 활성화")
@Builder.Default
private Boolean pushEnabled = false;
/**
* 회의 초대 알림 활성화 여부
*/
@Column(name = "invitation_enabled", nullable = false)
@Comment("회의 초대 알림 활성화")
@Builder.Default
private Boolean invitationEnabled = true;
/**
* Todo 할당 알림 활성화 여부
*/
@Column(name = "todo_assigned_enabled", nullable = false)
@Comment("Todo 할당 알림 활성화")
@Builder.Default
private Boolean todoAssignedEnabled = true;
/**
* Todo 리마인더 알림 활성화 여부
*/
@Column(name = "todo_reminder_enabled", nullable = false)
@Comment("Todo 리마인더 알림 활성화")
@Builder.Default
private Boolean todoReminderEnabled = true;
/**
* 회의 리마인더 알림 활성화 여부
*/
@Column(name = "meeting_reminder_enabled", nullable = false)
@Comment("회의 리마인더 알림 활성화")
@Builder.Default
private Boolean meetingReminderEnabled = true;
/**
* 회의록 수정 알림 활성화 여부
*/
@Column(name = "minutes_updated_enabled", nullable = false)
@Comment("회의록 수정 알림 활성화")
@Builder.Default
private Boolean minutesUpdatedEnabled = true;
/**
* Todo 완료 알림 활성화 여부
*/
@Column(name = "todo_completed_enabled", nullable = false)
@Comment("Todo 완료 알림 활성화")
@Builder.Default
private Boolean todoCompletedEnabled = false;
/**
* 방해 금지 모드 활성화 여부
*/
@Column(name = "dnd_enabled", nullable = false)
@Comment("방해 금지 모드 활성화")
@Builder.Default
private Boolean dndEnabled = false;
/**
* 방해 금지 시작 시간 (: 22:00)
*/
@Column(name = "dnd_start_time")
@Comment("방해 금지 시작 시간")
private LocalTime dndStartTime;
/**
* 방해 금지 종료 시간 (: 08:00)
*/
@Column(name = "dnd_end_time")
@Comment("방해 금지 종료 시간")
private LocalTime dndEndTime;
/**
* 생성 일시
*/
@Column(name = "created_at", nullable = false, updatable = false)
@Comment("생성 일시")
private LocalDateTime createdAt;
/**
* 수정 일시
*/
@Column(name = "updated_at")
@Comment("수정 일시")
private LocalDateTime updatedAt;
/**
* 생성 초기화
*
* - createdAt: 현재 시각
* - 기본값 설정
*/
@PrePersist
protected void onCreate() {
if (this.createdAt == null) {
this.createdAt = LocalDateTime.now();
}
}
/**
* 수정 업데이트
*
* - updatedAt: 현재 시각
*/
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
/**
* 알림 유형별 활성화 여부 확인
*
* @param notificationType 알림 유형
* @return 활성화 여부
*/
public boolean isNotificationTypeEnabled(Notification.NotificationType notificationType) {
return switch (notificationType) {
case INVITATION -> invitationEnabled;
case TODO_ASSIGNED -> todoAssignedEnabled;
case TODO_REMINDER -> todoReminderEnabled;
case MEETING_REMINDER -> meetingReminderEnabled;
case MINUTES_UPDATED -> minutesUpdatedEnabled;
case TODO_COMPLETED -> todoCompletedEnabled;
};
}
/**
* 채널별 활성화 여부 확인
*
* @param channel 알림 채널
* @return 활성화 여부
*/
public boolean isChannelEnabled(Notification.NotificationChannel channel) {
return switch (channel) {
case EMAIL -> emailEnabled;
case SMS -> smsEnabled;
case PUSH -> pushEnabled;
};
}
/**
* 방해 금지 시간대 여부 확인
*
* @return 방해 금지 시간대 여부
*/
public boolean isDoNotDisturbTime() {
if (!dndEnabled || dndStartTime == null || dndEndTime == null) {
return false;
}
LocalTime now = LocalTime.now();
// 시작 시간이 종료 시간보다 이전인 경우 (: 22:00 ~ 08:00)
if (dndStartTime.isBefore(dndEndTime)) {
return now.isAfter(dndStartTime) && now.isBefore(dndEndTime);
}
// 시작 시간이 종료 시간보다 이후인 경우 (자정을 넘는 경우)
else {
return now.isAfter(dndStartTime) || now.isBefore(dndEndTime);
}
}
/**
* 알림 발송 가능 여부 확인
*
* @param notificationType 알림 유형
* @param channel 알림 채널
* @return 발송 가능 여부
*/
public boolean canSendNotification(
Notification.NotificationType notificationType,
Notification.NotificationChannel channel
) {
// 채널 비활성화
if (!isChannelEnabled(channel)) {
return false;
}
// 알림 유형 비활성화
if (!isNotificationTypeEnabled(notificationType)) {
return false;
}
// 방해 금지 시간대
if (isDoNotDisturbTime()) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,74 @@
package com.unicorn.hgzero.notification.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalTime;
/**
* 알림 설정 업데이트 요청 DTO
*
* 사용자가 알림 설정을 변경할 사용
* 채널 활성화, 알림 유형, 방해 금지 시간대 설정
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "알림 설정 업데이트 요청")
public class UpdateSettingsRequest {
@Schema(description = "이메일 알림 활성화 여부", example = "true")
@NotNull(message = "이메일 알림 활성화 여부는 필수입니다")
private Boolean emailEnabled;
@Schema(description = "SMS 알림 활성화 여부", example = "false")
@NotNull(message = "SMS 알림 활성화 여부는 필수입니다")
private Boolean smsEnabled;
@Schema(description = "Push 알림 활성화 여부", example = "false")
@NotNull(message = "Push 알림 활성화 여부는 필수입니다")
private Boolean pushEnabled;
@Schema(description = "회의 초대 알림 활성화 여부", example = "true")
@NotNull(message = "회의 초대 알림 활성화 여부는 필수입니다")
private Boolean invitationEnabled;
@Schema(description = "Todo 할당 알림 활성화 여부", example = "true")
@NotNull(message = "Todo 할당 알림 활성화 여부는 필수입니다")
private Boolean todoAssignedEnabled;
@Schema(description = "Todo 리마인더 알림 활성화 여부", example = "true")
@NotNull(message = "Todo 리마인더 알림 활성화 여부는 필수입니다")
private Boolean todoReminderEnabled;
@Schema(description = "회의 리마인더 알림 활성화 여부", example = "true")
@NotNull(message = "회의 리마인더 알림 활성화 여부는 필수입니다")
private Boolean meetingReminderEnabled;
@Schema(description = "회의록 수정 알림 활성화 여부", example = "true")
@NotNull(message = "회의록 수정 알림 활성화 여부는 필수입니다")
private Boolean minutesUpdatedEnabled;
@Schema(description = "Todo 완료 알림 활성화 여부", example = "false")
@NotNull(message = "Todo 완료 알림 활성화 여부는 필수입니다")
private Boolean todoCompletedEnabled;
@Schema(description = "방해 금지 모드 활성화 여부", example = "false")
@NotNull(message = "방해 금지 모드 활성화 여부는 필수입니다")
private Boolean dndEnabled;
@Schema(description = "방해 금지 시작 시간 (HH:mm 형식)", example = "22:00")
private LocalTime dndStartTime;
@Schema(description = "방해 금지 종료 시간 (HH:mm 형식)", example = "08:00")
private LocalTime dndEndTime;
}

View File

@ -0,0 +1,83 @@
package com.unicorn.hgzero.notification.dto.response;
import com.unicorn.hgzero.notification.domain.Notification;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 알림 목록 응답 DTO
*
* 알림 목록 조회 사용
* 알림 기본 정보만 포함 (수신자 정보 제외)
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "알림 목록 응답")
public class NotificationListResponse {
@Schema(description = "알림 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String notificationId;
@Schema(description = "참조 대상 ID", example = "meeting-001")
private String referenceId;
@Schema(description = "참조 유형", example = "MEETING")
private String referenceType;
@Schema(description = "알림 유형", example = "INVITATION")
private String notificationType;
@Schema(description = "알림 제목", example = "회의 초대: 주간 회의")
private String title;
@Schema(description = "알림 상태", example = "SENT")
private String status;
@Schema(description = "발송 채널", example = "EMAIL")
private String channel;
@Schema(description = "발송 완료 건수", example = "5")
private Integer sentCount;
@Schema(description = "발송 실패 건수", example = "0")
private Integer failedCount;
@Schema(description = "생성 일시", example = "2025-01-23T10:00:00")
private LocalDateTime createdAt;
@Schema(description = "발송 완료 일시", example = "2025-01-23T10:05:00")
private LocalDateTime sentAt;
/**
* Entity를 DTO로 변환
*
* @param notification 알림 엔티티
* @return 알림 목록 응답 DTO
*/
public static NotificationListResponse from(Notification notification) {
return NotificationListResponse.builder()
.notificationId(notification.getNotificationId())
.referenceId(notification.getReferenceId())
.referenceType(notification.getReferenceType().name())
.notificationType(notification.getNotificationType().name())
.title(notification.getTitle())
.status(notification.getStatus().name())
.channel(notification.getChannel().name())
.sentCount(notification.getSentCount())
.failedCount(notification.getFailedCount())
.createdAt(notification.getCreatedAt())
.sentAt(notification.getSentAt())
.build();
}
}

View File

@ -0,0 +1,154 @@
package com.unicorn.hgzero.notification.dto.response;
import com.unicorn.hgzero.notification.domain.Notification;
import com.unicorn.hgzero.notification.domain.NotificationRecipient;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 알림 응답 DTO
*
* 단일 알림 정보 조회 사용
* 알림 기본 정보 수신자 목록 포함
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "알림 응답")
public class NotificationResponse {
@Schema(description = "알림 ID", example = "550e8400-e29b-41d4-a716-446655440000")
private String notificationId;
@Schema(description = "이벤트 ID (중복 방지용)", example = "meeting-created-20250123-001")
private String eventId;
@Schema(description = "참조 대상 ID", example = "meeting-001")
private String referenceId;
@Schema(description = "참조 유형", example = "MEETING")
private String referenceType;
@Schema(description = "알림 유형", example = "INVITATION")
private String notificationType;
@Schema(description = "알림 제목", example = "회의 초대: 주간 회의")
private String title;
@Schema(description = "알림 내용", example = "주간 진행 상황 공유 및 이슈 논의")
private String message;
@Schema(description = "알림 상태", example = "SENT")
private String status;
@Schema(description = "발송 채널", example = "EMAIL")
private String channel;
@Schema(description = "발송 완료 건수", example = "5")
private Integer sentCount;
@Schema(description = "발송 실패 건수", example = "0")
private Integer failedCount;
@Schema(description = "생성 일시", example = "2025-01-23T10:00:00")
private LocalDateTime createdAt;
@Schema(description = "발송 완료 일시", example = "2025-01-23T10:05:00")
private LocalDateTime sentAt;
@Schema(description = "수신자 목록")
private List<RecipientInfo> recipients;
/**
* Entity를 DTO로 변환
*
* @param notification 알림 엔티티
* @return 알림 응답 DTO
*/
public static NotificationResponse from(Notification notification) {
return NotificationResponse.builder()
.notificationId(notification.getNotificationId())
.eventId(notification.getEventId())
.referenceId(notification.getReferenceId())
.referenceType(notification.getReferenceType().name())
.notificationType(notification.getNotificationType().name())
.title(notification.getTitle())
.message(notification.getMessage())
.status(notification.getStatus().name())
.channel(notification.getChannel().name())
.sentCount(notification.getSentCount())
.failedCount(notification.getFailedCount())
.createdAt(notification.getCreatedAt())
.sentAt(notification.getSentAt())
.recipients(notification.getRecipients().stream()
.map(RecipientInfo::from)
.collect(Collectors.toList()))
.build();
}
/**
* 수신자 정보 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "수신자 정보")
public static class RecipientInfo {
@Schema(description = "수신자 ID", example = "550e8400-e29b-41d4-a716-446655440001")
private String recipientId;
@Schema(description = "수신자 사용자 ID", example = "user-001")
private String recipientUserId;
@Schema(description = "수신자 이름", example = "홍길동")
private String recipientName;
@Schema(description = "수신자 이메일", example = "hong@example.com")
private String recipientEmail;
@Schema(description = "발송 상태", example = "SENT")
private String status;
@Schema(description = "재시도 횟수", example = "0")
private Integer retryCount;
@Schema(description = "발송 일시", example = "2025-01-23T10:05:00")
private LocalDateTime sentAt;
@Schema(description = "실패 사유", example = null)
private String errorMessage;
/**
* Entity를 DTO로 변환
*
* @param recipient 수신자 엔티티
* @return 수신자 정보 DTO
*/
public static RecipientInfo from(NotificationRecipient recipient) {
return RecipientInfo.builder()
.recipientId(recipient.getRecipientId())
.recipientUserId(recipient.getRecipientUserId())
.recipientName(recipient.getRecipientName())
.recipientEmail(recipient.getRecipientEmail())
.status(recipient.getStatus().name())
.retryCount(recipient.getRetryCount())
.sentAt(recipient.getSentAt())
.errorMessage(recipient.getErrorMessage())
.build();
}
}
}

View File

@ -0,0 +1,100 @@
package com.unicorn.hgzero.notification.dto.response;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 알림 설정 응답 DTO
*
* 사용자 알림 설정 조회 사용
* 채널 활성화, 알림 유형, 방해 금지 시간대 정보 포함
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "알림 설정 응답")
public class SettingsResponse {
@Schema(description = "사용자 ID", example = "user-001")
private String userId;
@Schema(description = "이메일 알림 활성화 여부", example = "true")
private Boolean emailEnabled;
@Schema(description = "SMS 알림 활성화 여부", example = "false")
private Boolean smsEnabled;
@Schema(description = "Push 알림 활성화 여부", example = "false")
private Boolean pushEnabled;
@Schema(description = "회의 초대 알림 활성화 여부", example = "true")
private Boolean invitationEnabled;
@Schema(description = "Todo 할당 알림 활성화 여부", example = "true")
private Boolean todoAssignedEnabled;
@Schema(description = "Todo 리마인더 알림 활성화 여부", example = "true")
private Boolean todoReminderEnabled;
@Schema(description = "회의 리마인더 알림 활성화 여부", example = "true")
private Boolean meetingReminderEnabled;
@Schema(description = "회의록 수정 알림 활성화 여부", example = "true")
private Boolean minutesUpdatedEnabled;
@Schema(description = "Todo 완료 알림 활성화 여부", example = "false")
private Boolean todoCompletedEnabled;
@Schema(description = "방해 금지 모드 활성화 여부", example = "false")
private Boolean dndEnabled;
@Schema(description = "방해 금지 시작 시간", example = "22:00:00")
private LocalTime dndStartTime;
@Schema(description = "방해 금지 종료 시간", example = "08:00:00")
private LocalTime dndEndTime;
@Schema(description = "생성 일시", example = "2025-01-23T10:00:00")
private LocalDateTime createdAt;
@Schema(description = "수정 일시", example = "2025-01-23T10:05:00")
private LocalDateTime updatedAt;
/**
* Entity를 DTO로 변환
*
* @param setting 알림 설정 엔티티
* @return 알림 설정 응답 DTO
*/
public static SettingsResponse from(NotificationSetting setting) {
return SettingsResponse.builder()
.userId(setting.getUserId())
.emailEnabled(setting.getEmailEnabled())
.smsEnabled(setting.getSmsEnabled())
.pushEnabled(setting.getPushEnabled())
.invitationEnabled(setting.getInvitationEnabled())
.todoAssignedEnabled(setting.getTodoAssignedEnabled())
.todoReminderEnabled(setting.getTodoReminderEnabled())
.meetingReminderEnabled(setting.getMeetingReminderEnabled())
.minutesUpdatedEnabled(setting.getMinutesUpdatedEnabled())
.todoCompletedEnabled(setting.getTodoCompletedEnabled())
.dndEnabled(setting.getDndEnabled())
.dndStartTime(setting.getDndStartTime())
.dndEndTime(setting.getDndEndTime())
.createdAt(setting.getCreatedAt())
.updatedAt(setting.getUpdatedAt())
.build();
}
}

View File

@ -0,0 +1,177 @@
package com.unicorn.hgzero.notification.event;
import com.azure.messaging.eventhubs.models.EventContext;
import com.azure.messaging.eventhubs.models.EventData;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.unicorn.hgzero.notification.event.event.MeetingCreatedEvent;
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.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.function.Consumer;
/**
* Event Hub 이벤트 핸들러
*
* Azure Event Hub로부터 이벤트를 수신하여 처리
* 이벤트 유형에 따라 적절한 알림 발송 로직 실행
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EventHandler implements Consumer<EventContext> {
private final NotificationService notificationService;
private final ObjectMapper objectMapper;
private final RetryTemplate retryTemplate;
/**
* Event Hub 이벤트 처리
*
* @param eventContext Event Hub 이벤트 컨텍스트
*/
@Override
public void accept(EventContext eventContext) {
EventData eventData = eventContext.getEventData();
try {
// 이벤트 속성 추출
Map<String, Object> properties = eventData.getProperties();
String topic = (String) properties.get("topic");
String eventType = (String) properties.get("eventType");
log.info("이벤트 수신 - Topic: {}, EventType: {}", topic, eventType);
// 이벤트 본문 추출
String eventBody = eventData.getBodyAsString();
// 토픽 이벤트 유형에 따라 처리
if ("meeting".equals(topic)) {
handleMeetingEvent(eventType, eventBody);
} else if ("todo".equals(topic)) {
handleTodoEvent(eventType, eventBody);
} else {
log.warn("알 수 없는 토픽: {}", topic);
}
// 체크포인트 업데이트 (처리 성공 )
eventContext.updateCheckpoint();
log.info("이벤트 처리 완료 및 체크포인트 업데이트");
} catch (Exception e) {
log.error("이벤트 처리 중 오류 발생", e);
// 체크포인트를 업데이트하지 않아 재처리 가능
throw new RuntimeException("이벤트 처리 실패", e);
}
}
/**
* 회의 관련 이벤트 처리
*
* @param eventType 이벤트 유형
* @param eventBody 이벤트 본문 (JSON)
*/
private void handleMeetingEvent(String eventType, String eventBody) {
try {
switch (eventType) {
case "MeetingCreated":
MeetingCreatedEvent meetingEvent = objectMapper.readValue(
eventBody,
MeetingCreatedEvent.class
);
processMeetingCreatedEvent(meetingEvent);
break;
case "MeetingUpdated":
log.info("회의 수정 이벤트 처리 (향후 구현)");
break;
case "MeetingCancelled":
log.info("회의 취소 이벤트 처리 (향후 구현)");
break;
default:
log.warn("알 수 없는 회의 이벤트 유형: {}", eventType);
}
} catch (Exception e) {
log.error("회의 이벤트 처리 중 오류 발생 - EventType: {}", eventType, e);
throw new RuntimeException("회의 이벤트 처리 실패", e);
}
}
/**
* Todo 관련 이벤트 처리
*
* @param eventType 이벤트 유형
* @param eventBody 이벤트 본문 (JSON)
*/
private void handleTodoEvent(String eventType, String eventBody) {
try {
switch (eventType) {
case "TodoAssigned":
TodoAssignedEvent todoEvent = objectMapper.readValue(
eventBody,
TodoAssignedEvent.class
);
processTodoAssignedEvent(todoEvent);
break;
case "TodoCompleted":
log.info("Todo 완료 이벤트 처리 (향후 구현)");
break;
case "TodoUpdated":
log.info("Todo 수정 이벤트 처리 (향후 구현)");
break;
default:
log.warn("알 수 없는 Todo 이벤트 유형: {}", eventType);
}
} catch (Exception e) {
log.error("Todo 이벤트 처리 중 오류 발생 - EventType: {}", eventType, e);
throw new RuntimeException("Todo 이벤트 처리 실패", e);
}
}
/**
* 회의 생성 이벤트 처리 (재시도 지원)
*
* @param event 회의 생성 이벤트
*/
private void processMeetingCreatedEvent(MeetingCreatedEvent event) {
retryTemplate.execute(context -> {
log.info("회의 초대 알림 발송 시작 - MeetingId: {}, EventId: {}",
event.getMeetingId(), event.getEventId());
notificationService.sendMeetingInvitation(event);
log.info("회의 초대 알림 발송 완료 - MeetingId: {}", event.getMeetingId());
return null;
});
}
/**
* Todo 할당 이벤트 처리 (재시도 지원)
*
* @param event Todo 할당 이벤트
*/
private void processTodoAssignedEvent(TodoAssignedEvent event) {
retryTemplate.execute(context -> {
log.info("Todo 할당 알림 발송 시작 - TodoId: {}, EventId: {}",
event.getTodoId(), event.getEventId());
notificationService.sendTodoAssignment(event);
log.info("Todo 할당 알림 발송 완료 - TodoId: {}", event.getTodoId());
return null;
});
}
}

View File

@ -0,0 +1,100 @@
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;
/**
* 회의 생성 이벤트 DTO
*
* Meeting 서비스에서 회의 생성 발행되는 이벤트
* 회의 초대 알림 발송에 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MeetingCreatedEvent {
/**
* 이벤트 고유 ID (중복 발송 방지용)
*/
private String eventId;
/**
* 회의 ID
*/
private String meetingId;
/**
* 회의 제목
*/
private String title;
/**
* 회의 설명
*/
private String description;
/**
* 회의 시작 일시
*/
private LocalDateTime startTime;
/**
* 회의 종료 일시
*/
private LocalDateTime endTime;
/**
* 회의 장소
*/
private String location;
/**
* 회의 주최자 정보
*/
private ParticipantInfo organizer;
/**
* 참석자 목록
*/
private List<ParticipantInfo> participants;
/**
* 이벤트 발행 일시
*/
private LocalDateTime createdAt;
/**
* 참석자 정보 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class ParticipantInfo {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String name;
/**
* 사용자 이메일
*/
private String email;
}
}

View File

@ -0,0 +1,99 @@
package com.unicorn.hgzero.notification.event.event;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Todo 할당 이벤트 DTO
*
* Meeting 또는 AI 서비스에서 Todo 할당 발행되는 이벤트
* Todo 할당 알림 발송에 사용
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TodoAssignedEvent {
/**
* 이벤트 고유 ID (중복 발송 방지용)
*/
private String eventId;
/**
* Todo ID
*/
private String todoId;
/**
* Todo 제목
*/
private String title;
/**
* Todo 설명
*/
private String description;
/**
* 마감 기한
*/
private LocalDateTime deadline;
/**
* 우선순위 (HIGH, MEDIUM, LOW)
*/
private String priority;
/**
* 할당자 정보
*/
private UserInfo assignedBy;
/**
* 담당자 정보
*/
private UserInfo assignee;
/**
* 관련 회의 ID (회의에서 생성된 Todo인 경우)
*/
private String meetingId;
/**
* 이벤트 발행 일시
*/
private LocalDateTime createdAt;
/**
* 사용자 정보 DTO
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class UserInfo {
/**
* 사용자 ID
*/
private String userId;
/**
* 사용자 이름
*/
private String name;
/**
* 사용자 이메일
*/
private String email;
}
}

View File

@ -0,0 +1,65 @@
package com.unicorn.hgzero.notification.event.processor;
import com.azure.messaging.eventhubs.EventProcessorClient;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
/**
* Event Processor 라이프사이클 관리 서비스
*
* EventProcessorClient의 시작과 종료를 관리
* 애플리케이션 시작 이벤트 수신 시작, 종료 안전하게 정리
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EventProcessorService {
private final EventProcessorClient eventProcessorClient;
/**
* 애플리케이션 시작 Event Processor 시작
*
* @throws Exception 시작 실패 예외 발생
*/
@PostConstruct
@Retryable(
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2.0)
)
public void start() throws Exception {
try {
log.info("Event Processor 시작 중...");
eventProcessorClient.start();
log.info("Event Processor 시작 완료");
} catch (Exception e) {
log.error("Event Processor 시작 실패", e);
throw e;
}
}
/**
* 애플리케이션 종료 Event Processor 정리
*
* 처리 중인 이벤트를 안전하게 완료하고 리소스 정리
*/
@PreDestroy
public void stop() {
try {
log.info("Event Processor 종료 중...");
eventProcessorClient.stop();
log.info("Event Processor 종료 완료");
} catch (Exception e) {
log.error("Event Processor 종료 중 오류 발생", e);
}
}
}

View File

@ -0,0 +1,120 @@
package com.unicorn.hgzero.notification.repository;
import com.unicorn.hgzero.notification.domain.NotificationRecipient;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
/**
* 알림 수신자 Repository
*
* 수신자별 알림 상태 관리 재시도 대상 조회
* 발송 성공/실패 추적, 재시도 스케줄링 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Repository
public interface NotificationRecipientRepository extends JpaRepository<NotificationRecipient, String> {
/**
* 알림별 수신자 목록 조회
*
* @param notificationId 알림 ID
* @return 수신자 목록
*/
@Query("SELECT nr FROM NotificationRecipient nr WHERE nr.notification.notificationId = :notificationId")
List<NotificationRecipient> findByNotificationId(@Param("notificationId") String notificationId);
/**
* 재시도 대상 수신자 조회
*
* 상태가 RETRY이고 재시도 시간이 현재 시각 이전인 수신자 조회
*
* @param status 수신자 상태 (RETRY)
* @param now 현재 시각
* @return 재시도 대상 수신자 목록
*/
List<NotificationRecipient> findByStatusAndNextRetryAtBefore(
NotificationRecipient.RecipientStatus status,
LocalDateTime now
);
/**
* 이메일 주소로 수신자 히스토리 조회
*
* @param recipientEmail 수신자 이메일
* @return 수신자 목록
*/
List<NotificationRecipient> findByRecipientEmail(String recipientEmail);
/**
* 사용자 ID로 수신자 히스토리 조회
*
* @param recipientUserId 수신자 사용자 ID
* @return 수신자 목록
*/
List<NotificationRecipient> findByRecipientUserId(String recipientUserId);
/**
* 상태별 수신자 목록 조회
*
* @param status 수신자 상태
* @return 수신자 목록
*/
List<NotificationRecipient> findByStatus(NotificationRecipient.RecipientStatus status);
/**
* 특정 재시도 횟수 이상인 수신자 조회 (모니터링용)
*
* @param minRetryCount 최소 재시도 횟수
* @return 수신자 목록
*/
@Query("SELECT nr FROM NotificationRecipient nr WHERE nr.retryCount >= :minRetryCount")
List<NotificationRecipient> findRecipientsWithHighRetryCount(@Param("minRetryCount") int minRetryCount);
/**
* 발송 실패 수신자 목록 조회 (기간별)
*
* @param status 수신자 상태 (FAILED)
* @param startDate 시작 일시
* @param endDate 종료 일시
* @return 수신자 목록
*/
@Query("SELECT nr FROM NotificationRecipient nr WHERE nr.status = :status AND nr.createdAt BETWEEN :startDate AND :endDate")
List<NotificationRecipient> findFailedRecipientsByPeriod(
@Param("status") NotificationRecipient.RecipientStatus status,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
/**
* 알림별 상태별 수신자 개수 조회
*
* @param notificationId 알림 ID
* @param status 수신자 상태
* @return 수신자 개수
*/
@Query("SELECT COUNT(nr) FROM NotificationRecipient nr WHERE nr.notification.notificationId = :notificationId AND nr.status = :status")
long countByNotificationIdAndStatus(
@Param("notificationId") String notificationId,
@Param("status") NotificationRecipient.RecipientStatus status
);
/**
* 사용자별 발송 성공 알림 개수 조회 (통계용)
*
* @param recipientUserId 수신자 사용자 ID
* @param status 수신자 상태 (SENT)
* @return 발송 성공 개수
*/
long countByRecipientUserIdAndStatus(
String recipientUserId,
NotificationRecipient.RecipientStatus status
);
}

View File

@ -0,0 +1,110 @@
package com.unicorn.hgzero.notification.repository;
import com.unicorn.hgzero.notification.domain.Notification;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 알림 Repository
*
* 알림 이력 조회 저장을 담당
* 이벤트 ID 기반 중복 방지, 상태별 조회, 기간별 조회 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Repository
public interface NotificationRepository extends JpaRepository<Notification, String> {
/**
* 이벤트 ID로 알림 조회 (중복 발송 방지용)
*
* @param eventId 이벤트 고유 ID
* @return 알림 정보
*/
Optional<Notification> findByEventId(String eventId);
/**
* 참조 대상으로 알림 목록 조회
*
* @param referenceId 참조 대상 ID (meetingId 또는 todoId)
* @param referenceType 참조 유형 (MEETING, TODO)
* @return 알림 목록
*/
List<Notification> findByReferenceIdAndReferenceType(
String referenceId,
Notification.ReferenceType referenceType
);
/**
* 상태별 알림 목록 조회 (배치 처리용)
*
* @param statuses 조회할 상태 목록
* @return 알림 목록
*/
List<Notification> findByStatusIn(List<Notification.NotificationStatus> statuses);
/**
* 기간별 알림 목록 조회
*
* @param startDate 시작 일시
* @param endDate 종료 일시
* @return 알림 목록
*/
List<Notification> findByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate);
/**
* 알림 유형별 알림 목록 조회
*
* @param notificationType 알림 유형
* @return 알림 목록
*/
List<Notification> findByNotificationType(Notification.NotificationType notificationType);
/**
* 채널별 알림 목록 조회
*
* @param channel 발송 채널
* @return 알림 목록
*/
List<Notification> findByChannel(Notification.NotificationChannel channel);
/**
* 발송 실패 건수가 특정 이상인 알림 조회 (모니터링용)
*
* @param minFailedCount 최소 실패 건수
* @return 알림 목록
*/
@Query("SELECT n FROM Notification n WHERE n.failedCount >= :minFailedCount")
List<Notification> findNotificationsWithHighFailureRate(@Param("minFailedCount") int minFailedCount);
/**
* 이벤트 ID 존재 여부 확인 (중복 발송 방지용)
*
* @param eventId 이벤트 고유 ID
* @return 존재 여부
*/
boolean existsByEventId(String eventId);
/**
* 특정 상태이고 특정 기간 생성된 알림 개수 조회 (통계용)
*
* @param status 알림 상태
* @param startDate 시작 일시
* @param endDate 종료 일시
* @return 알림 개수
*/
@Query("SELECT COUNT(n) FROM Notification n WHERE n.status = :status AND n.createdAt BETWEEN :startDate AND :endDate")
long countByStatusAndCreatedAtBetween(
@Param("status") Notification.NotificationStatus status,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate
);
}

View File

@ -0,0 +1,133 @@
package com.unicorn.hgzero.notification.repository;
import com.unicorn.hgzero.notification.domain.NotificationSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 알림 설정 Repository
*
* 사용자별 알림 설정 관리
* 채널 활성화, 알림 유형, 방해 금지 시간대 설정
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Repository
public interface NotificationSettingRepository extends JpaRepository<NotificationSetting, String> {
/**
* 사용자 ID로 알림 설정 조회
*
* userId가 PK이므로 findById(userId) 동일하지만 명시적인 메서드명 제공
*
* @param userId 사용자 ID
* @return 알림 설정 (없으면 empty)
*/
Optional<NotificationSetting> findByUserId(String userId);
/**
* 사용자 ID 존재 여부 확인
*
* @param userId 사용자 ID
* @return 존재 여부
*/
boolean existsByUserId(String userId);
/**
* 이메일 알림 활성화된 사용자 목록 조회
*
* @param emailEnabled 이메일 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByEmailEnabled(boolean emailEnabled);
/**
* SMS 알림 활성화된 사용자 목록 조회
*
* @param smsEnabled SMS 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findBySmsEnabled(boolean smsEnabled);
/**
* Push 알림 활성화된 사용자 목록 조회
*
* @param pushEnabled Push 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByPushEnabled(boolean pushEnabled);
/**
* 방해 금지 모드 활성화된 사용자 목록 조회
*
* @param dndEnabled 방해 금지 모드 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByDndEnabled(boolean dndEnabled);
/**
* 특정 알림 유형이 활성화된 사용자 개수 조회 (통계용)
*
* : 회의 초대 알림을 활성화한 사용자
*
* @param invitationEnabled 회의 초대 알림 활성화 여부
* @return 사용자 개수
*/
long countByInvitationEnabled(boolean invitationEnabled);
/**
* Todo 할당 알림이 활성화된 사용자 개수 조회 (통계용)
*
* @param todoAssignedEnabled Todo 할당 알림 활성화 여부
* @return 사용자 개수
*/
long countByTodoAssignedEnabled(boolean todoAssignedEnabled);
/**
* 이메일과 특정 알림 유형이 모두 활성화된 사용자 조회
*
* 발송 대상 필터링용
*
* @param emailEnabled 이메일 알림 활성화 여부
* @param invitationEnabled 회의 초대 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByEmailEnabledAndInvitationEnabled(
boolean emailEnabled,
boolean invitationEnabled
);
/**
* 이메일과 Todo 할당 알림이 모두 활성화된 사용자 조회
*
* @param emailEnabled 이메일 알림 활성화 여부
* @param todoAssignedEnabled Todo 할당 알림 활성화 여부
* @return 알림 설정 목록
*/
List<NotificationSetting> findByEmailEnabledAndTodoAssignedEnabled(
boolean emailEnabled,
boolean todoAssignedEnabled
);
/**
* 모든 알림 채널이 비활성화된 사용자 조회 (모니터링용)
*
* @param emailEnabled 이메일 알림 활성화 여부
* @param smsEnabled SMS 알림 활성화 여부
* @param pushEnabled Push 알림 활성화 여부
* @return 알림 설정 목록
*/
@Query("SELECT ns FROM NotificationSetting ns WHERE ns.emailEnabled = :emailEnabled AND ns.smsEnabled = :smsEnabled AND ns.pushEnabled = :pushEnabled")
List<NotificationSetting> findUsersWithAllChannelsDisabled(
@Param("emailEnabled") boolean emailEnabled,
@Param("smsEnabled") boolean smsEnabled,
@Param("pushEnabled") boolean pushEnabled
);
}

View File

@ -0,0 +1,108 @@
package com.unicorn.hgzero.notification.service;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.stereotype.Component;
/**
* 이메일 발송 클라이언트
*
* SMTP를 통한 이메일 발송 처리
* HTML 템플릿 기반 이메일 전송 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class EmailClient {
private final JavaMailSender mailSender;
/**
* HTML 이메일 발송 (재시도 지원)
*
* 발송 실패 최대 3번까지 재시도
* Exponential Backoff: 초기 5분, 최대 30분, 배수 2.0
*
* @param to 수신자 이메일 주소
* @param subject 이메일 제목
* @param htmlContent HTML 이메일 본문
* @throws MessagingException 이메일 발송 실패
*/
@Retryable(
retryFor = {MessagingException.class},
maxAttempts = 3,
backoff = @Backoff(
delay = 300000, // 5분
maxDelay = 1800000, // 30분
multiplier = 2.0
)
)
public void sendHtmlEmail(String to, String subject, String htmlContent) throws MessagingException {
try {
log.info("이메일 발송 시작 - To: {}, Subject: {}", to, subject);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(htmlContent, true); // true = HTML 모드
mailSender.send(message);
log.info("이메일 발송 완료 - To: {}", to);
} catch (MessagingException e) {
log.error("이메일 발송 실패 - To: {}, Subject: {}", to, subject, e);
throw e;
}
}
/**
* 텍스트 이메일 발송 (재시도 지원)
*
* @param to 수신자 이메일 주소
* @param subject 이메일 제목
* @param textContent 텍스트 이메일 본문
* @throws MessagingException 이메일 발송 실패
*/
@Retryable(
retryFor = {MessagingException.class},
maxAttempts = 3,
backoff = @Backoff(
delay = 300000,
maxDelay = 1800000,
multiplier = 2.0
)
)
public void sendTextEmail(String to, String subject, String textContent) throws MessagingException {
try {
log.info("텍스트 이메일 발송 시작 - To: {}, Subject: {}", to, subject);
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(to);
helper.setSubject(subject);
helper.setText(textContent, false); // false = 텍스트 모드
mailSender.send(message);
log.info("텍스트 이메일 발송 완료 - To: {}", to);
} catch (MessagingException e) {
log.error("텍스트 이메일 발송 실패 - To: {}, Subject: {}", to, subject, e);
throw e;
}
}
}

View File

@ -0,0 +1,143 @@
package com.unicorn.hgzero.notification.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import java.util.Map;
/**
* 이메일 템플릿 렌더링 서비스
*
* Thymeleaf 템플릿 엔진을 사용하여 동적 HTML 이메일 생성
* 회의 초대, Todo 할당 다양한 알림 템플릿 지원
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EmailTemplateService {
private final TemplateEngine templateEngine;
/**
* 회의 초대 이메일 템플릿 렌더링
*
* @param variables 템플릿 변수
* - title: 회의 제목
* - description: 회의 설명
* - startTime: 시작 시간
* - endTime: 종료 시간
* - location: 장소
* - organizerName: 주최자 이름
* - participantName: 참석자 이름
* @return 렌더링된 HTML 문자열
*/
public String renderMeetingInvitation(Map<String, Object> variables) {
try {
log.info("회의 초대 템플릿 렌더링 시작");
Context context = new Context();
context.setVariables(variables);
String html = templateEngine.process("meeting-invitation", context);
log.info("회의 초대 템플릿 렌더링 완료");
return html;
} catch (Exception e) {
log.error("회의 초대 템플릿 렌더링 실패", e);
throw new RuntimeException("템플릿 렌더링 실패: meeting-invitation", e);
}
}
/**
* Todo 할당 이메일 템플릿 렌더링
*
* @param variables 템플릿 변수
* - title: Todo 제목
* - description: Todo 설명
* - deadline: 마감 기한
* - priority: 우선순위
* - assignedByName: 할당자 이름
* - assigneeName: 담당자 이름
* - meetingTitle: 관련 회의 제목 (optional)
* @return 렌더링된 HTML 문자열
*/
public String renderTodoAssigned(Map<String, Object> variables) {
try {
log.info("Todo 할당 템플릿 렌더링 시작");
Context context = new Context();
context.setVariables(variables);
String html = templateEngine.process("todo-assigned", context);
log.info("Todo 할당 템플릿 렌더링 완료");
return html;
} catch (Exception e) {
log.error("Todo 할당 템플릿 렌더링 실패", e);
throw new RuntimeException("템플릿 렌더링 실패: todo-assigned", e);
}
}
/**
* 리마인더 이메일 템플릿 렌더링
*
* @param variables 템플릿 변수
* - reminderType: 리마인더 유형 (meeting, todo)
* - title: 제목
* - description: 설명
* - scheduledTime: 예정 시간
* - recipientName: 수신자 이름
* @return 렌더링된 HTML 문자열
*/
public String renderReminder(Map<String, Object> variables) {
try {
log.info("리마인더 템플릿 렌더링 시작");
Context context = new Context();
context.setVariables(variables);
String html = templateEngine.process("reminder", context);
log.info("리마인더 템플릿 렌더링 완료");
return html;
} catch (Exception e) {
log.error("리마인더 템플릿 렌더링 실패", e);
throw new RuntimeException("템플릿 렌더링 실패: reminder", e);
}
}
/**
* 일반 템플릿 렌더링
*
* @param templateName 템플릿 이름 (확장자 제외)
* @param variables 템플릿 변수
* @return 렌더링된 HTML 문자열
*/
public String render(String templateName, Map<String, Object> variables) {
try {
log.info("템플릿 렌더링 시작 - Template: {}", templateName);
Context context = new Context();
context.setVariables(variables);
String html = templateEngine.process(templateName, context);
log.info("템플릿 렌더링 완료 - Template: {}", templateName);
return html;
} catch (Exception e) {
log.error("템플릿 렌더링 실패 - Template: {}", templateName, e);
throw new RuntimeException("템플릿 렌더링 실패: " + templateName, e);
}
}
}

View File

@ -0,0 +1,277 @@
package com.unicorn.hgzero.notification.service;
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.TodoAssignedEvent;
import com.unicorn.hgzero.notification.repository.NotificationRecipientRepository;
import com.unicorn.hgzero.notification.repository.NotificationRepository;
import com.unicorn.hgzero.notification.repository.NotificationSettingRepository;
import jakarta.mail.MessagingException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 알림 발송 비즈니스 로직 서비스
*
* 회의 초대, Todo 할당 다양한 알림 발송 처리
* 중복 방지, 사용자 설정 확인, 재시도 관리 포함
*
* @author 준호 (Backend Developer)
* @version 1.0
* @since 2025-10-23
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class NotificationService {
private final NotificationRepository notificationRepository;
private final NotificationRecipientRepository recipientRepository;
private final NotificationSettingRepository settingRepository;
private final EmailTemplateService templateService;
private final EmailClient emailClient;
private static final DateTimeFormatter DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
/**
* 회의 초대 알림 발송
*
* @param event 회의 생성 이벤트
*/
public void sendMeetingInvitation(MeetingCreatedEvent event) {
log.info("회의 초대 알림 처리 시작 - MeetingId: {}, EventId: {}",
event.getMeetingId(), event.getEventId());
// 1. 중복 발송 방지 체크
if (notificationRepository.existsByEventId(event.getEventId())) {
log.warn("이미 처리된 이벤트 - EventId: {}", event.getEventId());
return;
}
// 2. 알림 엔티티 생성
Notification notification = Notification.builder()
.eventId(event.getEventId())
.referenceId(event.getMeetingId())
.referenceType(Notification.ReferenceType.MEETING)
.notificationType(Notification.NotificationType.INVITATION)
.title("회의 초대: " + event.getTitle())
.message(event.getDescription())
.status(Notification.NotificationStatus.PROCESSING)
.channel(Notification.NotificationChannel.EMAIL)
.build();
notificationRepository.save(notification);
// 3. 참석자에게 알림 발송
int successCount = 0;
int failureCount = 0;
for (MeetingCreatedEvent.ParticipantInfo participant : event.getParticipants()) {
try {
// 3-1. 알림 설정 확인
if (!canSendNotification(participant.getUserId(), Notification.NotificationType.INVITATION)) {
log.info("알림 설정에 의해 발송 제외 - UserId: {}", participant.getUserId());
continue;
}
// 3-2. 수신자 엔티티 생성
NotificationRecipient recipient = NotificationRecipient.builder()
.recipientUserId(participant.getUserId())
.recipientName(participant.getName())
.recipientEmail(participant.getEmail())
.status(NotificationRecipient.RecipientStatus.PENDING)
.build();
notification.addRecipient(recipient);
// 3-3. 이메일 템플릿 렌더링
Map<String, Object> variables = new HashMap<>();
variables.put("title", event.getTitle());
variables.put("description", event.getDescription());
variables.put("startTime", event.getStartTime().format(DATETIME_FORMATTER));
variables.put("endTime", event.getEndTime().format(DATETIME_FORMATTER));
variables.put("location", event.getLocation());
variables.put("organizerName", event.getOrganizer().getName());
variables.put("participantName", participant.getName());
String htmlContent = templateService.renderMeetingInvitation(variables);
// 3-4. 이메일 발송
emailClient.sendHtmlEmail(
participant.getEmail(),
"회의 초대: " + event.getTitle(),
htmlContent
);
// 3-5. 발송 성공 처리
recipient.markAsSent();
notification.incrementSentCount();
successCount++;
log.info("회의 초대 알림 발송 성공 - Email: {}", participant.getEmail());
} catch (MessagingException e) {
// 3-6. 발송 실패 처리
NotificationRecipient recipient = notification.getRecipients().stream()
.filter(r -> r.getRecipientUserId().equals(participant.getUserId()))
.findFirst()
.orElse(null);
if (recipient != null) {
recipient.markAsFailed(e.getMessage());
notification.incrementFailedCount();
}
failureCount++;
log.error("회의 초대 알림 발송 실패 - Email: {}", participant.getEmail(), e);
}
}
// 4. 알림 상태 업데이트
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);
}
/**
* Todo 할당 알림 발송
*
* @param event Todo 할당 이벤트
*/
public void sendTodoAssignment(TodoAssignedEvent event) {
log.info("Todo 할당 알림 처리 시작 - TodoId: {}, EventId: {}",
event.getTodoId(), event.getEventId());
// 1. 중복 발송 방지 체크
if (notificationRepository.existsByEventId(event.getEventId())) {
log.warn("이미 처리된 이벤트 - EventId: {}", event.getEventId());
return;
}
// 2. 알림 엔티티 생성
Notification notification = Notification.builder()
.eventId(event.getEventId())
.referenceId(event.getTodoId())
.referenceType(Notification.ReferenceType.TODO)
.notificationType(Notification.NotificationType.TODO_ASSIGNED)
.title("Todo 할당: " + event.getTitle())
.message(event.getDescription())
.status(Notification.NotificationStatus.PROCESSING)
.channel(Notification.NotificationChannel.EMAIL)
.build();
notificationRepository.save(notification);
try {
// 3. 알림 설정 확인
TodoAssignedEvent.UserInfo assignee = event.getAssignee();
if (!canSendNotification(assignee.getUserId(), Notification.NotificationType.TODO_ASSIGNED)) {
log.info("알림 설정에 의해 발송 제외 - UserId: {}", assignee.getUserId());
notification.updateStatus(Notification.NotificationStatus.SENT);
return;
}
// 4. 수신자 엔티티 생성
NotificationRecipient recipient = NotificationRecipient.builder()
.recipientUserId(assignee.getUserId())
.recipientName(assignee.getName())
.recipientEmail(assignee.getEmail())
.status(NotificationRecipient.RecipientStatus.PENDING)
.build();
notification.addRecipient(recipient);
// 5. 이메일 템플릿 렌더링
Map<String, Object> variables = new HashMap<>();
variables.put("title", event.getTitle());
variables.put("description", event.getDescription());
variables.put("deadline", event.getDeadline().format(DATETIME_FORMATTER));
variables.put("priority", event.getPriority());
variables.put("assignedByName", event.getAssignedBy().getName());
variables.put("assigneeName", assignee.getName());
// 회의 관련 Todo인 경우 회의 정보 추가
if (event.getMeetingId() != null) {
variables.put("meetingId", event.getMeetingId());
}
String htmlContent = templateService.renderTodoAssigned(variables);
// 6. 이메일 발송
emailClient.sendHtmlEmail(
assignee.getEmail(),
"Todo 할당: " + event.getTitle(),
htmlContent
);
// 7. 발송 성공 처리
recipient.markAsSent();
notification.incrementSentCount();
notification.updateStatus(Notification.NotificationStatus.SENT);
log.info("Todo 할당 알림 발송 성공 - Email: {}", assignee.getEmail());
} catch (MessagingException e) {
// 8. 발송 실패 처리
NotificationRecipient recipient = notification.getRecipients().stream()
.findFirst()
.orElse(null);
if (recipient != null) {
recipient.markAsFailed(e.getMessage());
}
notification.incrementFailedCount();
notification.updateStatus(Notification.NotificationStatus.FAILED);
log.error("Todo 할당 알림 발송 실패", e);
}
notificationRepository.save(notification);
log.info("Todo 할당 알림 처리 완료");
}
/**
* 알림 발송 가능 여부 확인
*
* 사용자 알림 설정, 채널, 알림 유형, 방해 금지 시간대 체크
*
* @param userId 사용자 ID
* @param notificationType 알림 유형
* @return 발송 가능 여부
*/
private boolean canSendNotification(String userId, Notification.NotificationType notificationType) {
Optional<NotificationSetting> settingOpt = settingRepository.findByUserId(userId);
// 설정이 없으면 기본값으로 발송 허용 (이메일, 초대/할당 알림만)
if (settingOpt.isEmpty()) {
return notificationType == Notification.NotificationType.INVITATION
|| notificationType == Notification.NotificationType.TODO_ASSIGNED;
}
NotificationSetting setting = settingOpt.get();
// 이메일 채널 알림 유형 활성화 여부 확인
return setting.canSendNotification(notificationType, Notification.NotificationChannel.EMAIL);
}
}

View File

@ -87,6 +87,11 @@ azure:
name: ${AZURE_EVENTHUB_NAME:notification-events} name: ${AZURE_EVENTHUB_NAME:notification-events}
consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default} consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:$Default}
# Azure Blob Storage Configuration (for Event Hub Checkpoint)
storage:
connection-string: ${AZURE_STORAGE_CONNECTION_STRING:}
container-name: ${AZURE_STORAGE_CONTAINER_NAME:eventhub-checkpoints}
# Notification Configuration # Notification Configuration
notification: notification:
from-email: ${NOTIFICATION_FROM_EMAIL:noreply@hgzero.com} from-email: ${NOTIFICATION_FROM_EMAIL:noreply@hgzero.com}