mirror of
https://github.com/hwanny1128/HGZero.git
synced 2025-12-06 19:36:23 +00:00
Merge branch 'main' of https://github.com/hwanny1128/HGZero
This commit is contained in:
commit
b8cd502c44
@ -2,10 +2,8 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"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 push)",
|
||||
"Bash(git pull:*)",
|
||||
"Bash(git commit:*)"
|
||||
"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)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
BIN
.gradle/8.14/checksums/checksums.lock
Normal file
BIN
.gradle/8.14/checksums/checksums.lock
Normal file
Binary file not shown.
BIN
.gradle/8.14/fileChanges/last-build.bin
Normal file
BIN
.gradle/8.14/fileChanges/last-build.bin
Normal file
Binary file not shown.
BIN
.gradle/8.14/fileHashes/fileHashes.lock
Normal file
BIN
.gradle/8.14/fileHashes/fileHashes.lock
Normal file
Binary file not shown.
0
.gradle/8.14/gc.properties
Normal file
0
.gradle/8.14/gc.properties
Normal file
BIN
.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
BIN
.gradle/buildOutputCleanup/buildOutputCleanup.lock
Normal file
Binary file not shown.
2
.gradle/buildOutputCleanup/cache.properties
Normal file
2
.gradle/buildOutputCleanup/cache.properties
Normal file
@ -0,0 +1,2 @@
|
||||
#Thu Oct 23 15:10:42 KST 2025
|
||||
gradle.version=8.14
|
||||
0
.gradle/vcs-1/gc.properties
Normal file
0
.gradle/vcs-1/gc.properties
Normal file
@ -39,35 +39,49 @@ spring:
|
||||
max-idle: 8
|
||||
min-idle: 0
|
||||
max-wait: -1ms
|
||||
database: ${REDIS_DATABASE:3}
|
||||
database: ${REDIS_DATABASE:4}
|
||||
|
||||
# Server Configuration
|
||||
server:
|
||||
port: ${SERVER_PORT:8083}
|
||||
port: ${SERVER_PORT:8084}
|
||||
servlet:
|
||||
context-path: ${CONTEXT_PATH:}
|
||||
|
||||
# JWT Configuration
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:3600}
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:604800}
|
||||
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:1800}
|
||||
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400}
|
||||
|
||||
# CORS Configuration
|
||||
cors:
|
||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:*}
|
||||
|
||||
# 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 OpenAI Configuration
|
||||
azure:
|
||||
aisearch:
|
||||
endpoint: ${AZURE_AISEARCH_ENDPOINT:}
|
||||
api-key: ${AZURE_AISEARCH_API_KEY:}
|
||||
index-name: ${AZURE_AISEARCH_INDEX_NAME:minutes-index}
|
||||
openai:
|
||||
api-key: ${AZURE_OPENAI_API_KEY:}
|
||||
endpoint: ${AZURE_OPENAI_ENDPOINT:}
|
||||
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
|
||||
management:
|
||||
|
||||
663
build/reports/problems/problems-report.html
Normal file
663
build/reports/problems/problems-report.html
Normal file
File diff suppressed because one or more lines are too long
@ -555,6 +555,60 @@
|
||||
</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>
|
||||
let currentFilter = 'all';
|
||||
@ -674,6 +728,14 @@
|
||||
${createProgressBar(todo.progress)}
|
||||
</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>
|
||||
`);
|
||||
@ -735,6 +797,132 @@
|
||||
// 실제로는 폼 데이터를 수집하여 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) {
|
||||
const element = $(`#${elementId}`);
|
||||
|
||||
@ -2,9 +2,9 @@
|
||||
|
||||
## 문서 정보
|
||||
- **작성일**: 2025-10-21
|
||||
- **최종 수정일**: 2025-10-22
|
||||
- **최종 수정일**: 2025-10-23
|
||||
- **작성자**: 이미준 (서비스 기획자)
|
||||
- **버전**: 1.4
|
||||
- **버전**: 1.4.4
|
||||
- **설계 철학**: Mobile First Design
|
||||
|
||||
---
|
||||
@ -1091,16 +1091,17 @@ graph TD
|
||||
|
||||
#### 개요
|
||||
- **목적**: 할당된 Todo 목록 조회 및 진행 상황 관리
|
||||
- **관련 유저스토리**: UFR-TODO-010, UFR-TODO-030
|
||||
- **관련 유저스토리**: UFR-TODO-010, UFR-TODO-030, **UFR-TODO-040 (Todo 수정)**
|
||||
- **비즈니스 중요도**: 높음
|
||||
- **접근 경로**: 대시보드 → 하단 네비게이션 "Todo" 또는 대시보드 "내 Todo" 카드 → "전체 보기"
|
||||
|
||||
#### 주요 기능
|
||||
1. Todo 목록 표시 (상태별 필터링)
|
||||
2. Todo 완료 처리
|
||||
3. 회의록 원문으로 이동 (양방향 연결)
|
||||
4. Todo 진행 상황 통계
|
||||
5. 마감 임박 Todo 알림
|
||||
2. **Todo 수정 (UFR-TODO-040)** - 신규 추가
|
||||
3. Todo 완료 처리
|
||||
4. 회의록 원문으로 이동 (양방향 연결)
|
||||
5. Todo 진행 상황 통계
|
||||
6. 마감 임박 Todo 알림
|
||||
|
||||
#### UI 구성요소
|
||||
|
||||
@ -1124,6 +1125,7 @@ graph TD
|
||||
- 마감일 (색상 코딩: 초록-여유, 노랑-임박, 빨강-지연)
|
||||
- 우선순위 배지 (높음/보통/낮음)
|
||||
- 관련 회의록 링크 아이콘
|
||||
- **"편집" 버튼** (본인 담당 Todo만 표시) - 신규
|
||||
- 스와이프 액션: 수정, 삭제
|
||||
|
||||
- **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는 리스트 하단으로 이동 (취소선)
|
||||
|
||||
2. **회의록 연결**
|
||||
3. **회의록 연결**
|
||||
- 회의록 링크 아이콘 클릭:
|
||||
- 회의록상세조회 화면으로 이동
|
||||
- 해당 Todo가 언급된 섹션으로 자동 스크롤
|
||||
- 하이라이트 효과
|
||||
|
||||
3. **필터링**
|
||||
4. **필터링**
|
||||
- 필터 탭 클릭: 해당 상태의 Todo만 표시
|
||||
- 마감 임박: 3일 이내 마감 Todo
|
||||
|
||||
4. **수동 추가**
|
||||
5. **수동 추가**
|
||||
- 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. 회의록 내용 수정 (섹션별)
|
||||
3. **AI 요약 수정** (섹션별)
|
||||
4. **참고자료 편집** (추가/제거)
|
||||
5. 자동 저장 (30초 간격)
|
||||
6. 수정 이력 관리
|
||||
7. 상태 변경 (확정완료 → 작성중)
|
||||
5. **Todo 수정 (UFR-TODO-040)** - 신규 추가 (회의 생성자만)
|
||||
6. 자동 저장 (30초 간격)
|
||||
7. 수정 이력 관리
|
||||
8. 상태 변경 (확정완료 → 작성중)
|
||||
|
||||
#### UI 구성요소
|
||||
|
||||
@ -1407,6 +1428,17 @@ graph TD
|
||||
- 기존 참고자료 목록 (제거 버튼 포함)
|
||||
- "참고자료 추가" 버튼
|
||||
- 회의록 검색 및 선택 UI
|
||||
- **Todo 섹션 편집 영역** (회의 생성자만) - 신규
|
||||
- Todo 목록 표시
|
||||
- 각 Todo 항목:
|
||||
- 체크박스 (완료 상태)
|
||||
- Todo 제목
|
||||
- 담당자 (변경 가능)
|
||||
- 마감일 (변경 가능)
|
||||
- 우선순위 (변경 가능)
|
||||
- "편집" 버튼 (인라인 편집 활성화)
|
||||
- "삭제" 버튼
|
||||
- "Todo 추가" 버튼
|
||||
- 검증 완료 체크박스 (잠금 해제 필요)
|
||||
- 자동 저장 상태 표시 ("저장됨", "저장 중...")
|
||||
|
||||
@ -1446,7 +1478,35 @@ graph TD
|
||||
- 제거 버튼 (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.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.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.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,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)
|
||||
- [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)
|
||||
@ -976,5 +1037,6 @@ UFR-TODO-030: [Todo완료처리] Todo 담당자로서 | 나는, 완료된 Todo
|
||||
| 1.0 | 2025-01-20 | 도그냥 (서비스 기획자) | 초안 작성 (8개 마이크로서비스) |
|
||||
| 2.0 | 2025-01-22 | 길동 (아키텍트) | 논리 아키텍처 반영 (5개 마이크로서비스로 단순화) |
|
||||
| 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-회의록수정 화면에서 수정 가능 |
|
||||
|
||||
---
|
||||
623
develop/dev/dev-notification.md
Normal file
623
develop/dev/dev-notification.md
Normal 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
|
||||
146
develop/dev/package-structure-notification.md
Normal file
146
develop/dev/package-structure-notification.md
Normal 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)
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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 알림
|
||||
}
|
||||
}
|
||||
@ -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 // 재시도 예정
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -87,6 +87,11 @@ azure:
|
||||
name: ${AZURE_EVENTHUB_NAME:notification-events}
|
||||
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:
|
||||
from-email: ${NOTIFICATION_FROM_EMAIL:noreply@hgzero.com}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user