add new meeting

This commit is contained in:
djeon 2025-10-23 17:23:52 +09:00
parent b591cca33a
commit 7e06bb412f
16 changed files with 1445 additions and 28 deletions

175
claude/make-run-profile.md Normal file
View File

@ -0,0 +1,175 @@
# 서비스실행파일작성가이드
[요청사항]
- <수행원칙>을 준용하여 수행
- <수행순서>에 따라 수행
- [결과파일] 안내에 따라 파일 작성
[가이드]
<수행원칙>
- 설정 Manifest(src/main/resources/application*.yml)의 각 항목의 값은 하드코딩하지 않고 환경변수 처리
- Kubernetes에 배포된 데이터베이스는 LoadBalacer유형의 Service를 만들어 연결
- MQ 이용 시 'MQ설치결과서'의 연결 정보를 실행 프로파일의 환경변수로 등록
<수행순서>
- 준비:
- 데이터베이스설치결과서(develop/database/exec/db-exec-dev.md) 분석
- 캐시설치결과서(develop/database/exec/cache-exec-dev.md) 분석
- MQ설치결과서(develop/mq/mq-exec-dev.md) 분석 - 연결 정보 확인
- kubectl get svc -n tripgen-dev | grep LoadBalancer 실행하여 External IP 목록 확인
- 실행:
- 각 서비스별를 서브에이젼트로 병렬 수행
- 설정 Manifest 수정
- 하드코딩 되어 있는 값이 있으면 환경변수로 변환
- 특히, 데이터베이스, MQ 등의 연결 정보는 반드시 환경변수로 변환해야 함
- 민감한 정보의 디퐅트값은 생략하거나 간략한 값으로 지정
- '<로그설정>'을 참조하여 Log 파일 설정
- '<실행프로파일 작성 가이드>'에 따라 서비스 실행프로파일 작성
- LoadBalancer External IP를 DB_HOST, REDIS_HOST로 설정
- MQ 연결 정보를 application.yml의 환경변수명에 맞춰 설정
- 서비스 실행 및 오류 수정
- 'IntelliJ서비스실행기'를 'tools' 디렉토리에 다운로드
- python 또는 python3 명령으로 백그라우드로 실행하고 결과 로그를 분석
nohup python3 tools/run-intellij-service-profile.py {service-name} > logs/{service-name}.log 2>&1 & echo "Started {service-name} with PID: $!"
- 서비스 실행은 다른 방법 사용하지 말고 **반드시 python 프로그램 이용**
- 오류 수정 후 필요 시 실행파일의 환경변수를 올바르게 변경
- 서비스 정상 시작 확인 후 서비스 중지
- 결과: {service-name}/.run
<서비스 중지 방법>
- Window
- netstat -ano | findstr :{PORT}
- powershell "Stop-Process -Id {Process number} -Force"
- Linux/Mac
- netstat -ano | grep {PORT}
- kill -9 {Process number}
<로그설정>
- **application.yml 로그 파일 설정**:
```yaml
logging:
file:
name: ${LOG_FILE:logs/trip-service.log}
logback:
rollingpolicy:
max-file-size: 10MB
max-history: 7
total-size-cap: 100MB
```
<실행프로파일 작성 가이드>
- {service-name}/.run/{service-name}.run.xml 파일로 작성
- Spring Boot가 아니고 **Gradle 실행 프로파일**이어야 함: '[실행프로파일 예시]' 참조
- Kubernetes에 배포된 데이터베이스의 LoadBalancer Service 확인:
- kubectl get svc -n {namespace} | grep LoadBalancer 명령으로 LoadBalancer IP 확인
- 각 서비스별 데이터베이스의 LoadBalancer External IP를 DB_HOST로 사용
- 캐시(Redis)의 LoadBalancer External IP를 REDIS_HOST로 사용
- MQ 연결 설정:
- MQ설치결과서(develop/mq/mq-exec-dev.md)에서 연결 정보 확인
- MQ 유형에 따른 연결 정보 설정 예시:
- RabbitMQ: RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_USERNAME, RABBITMQ_PASSWORD
- Kafka: KAFKA_BOOTSTRAP_SERVERS, KAFKA_SECURITY_PROTOCOL
- Azure Service Bus: SERVICE_BUS_CONNECTION_STRING
- AWS SQS: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
- Redis (Pub/Sub): REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
- ActiveMQ: ACTIVEMQ_BROKER_URL, ACTIVEMQ_USER, ACTIVEMQ_PASSWORD
- 기타 MQ: 해당 MQ의 연결에 필요한 호스트, 포트, 인증정보, 연결문자열 등을 환경변수로 설정
- application.yml에 정의된 환경변수명 확인 후 매핑
- 백킹서비스 연결 정보 매핑:
- 데이터베이스설치결과서에서 각 서비스별 DB 인증 정보 확인
- 캐시설치결과서에서 각 서비스별 Redis 인증 정보 확인
- LoadBalancer의 External IP를 호스트로 사용 (내부 DNS 아님)
- 개발모드의 DDL_AUTO값은 update로 함
- JWT Secret Key는 모든 서비스가 동일해야 함
- application.yaml의 환경변수와 일치하도록 환경변수 설정
- application.yaml의 민감 정보는 기본값으로 지정하지 않고 실제 백킹서비스 정보로 지정
- 백킹서비스 연결 확인 결과를 바탕으로 정확한 값을 지정
- 기존에 파일이 있으면 내용을 분석하여 항목 추가/수정/삭제
[실행프로파일 예시]
```
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="user-service" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<entry key="ACCOUNT_LOCK_DURATION_MINUTES" value="30" />
<entry key="CACHE_TTL" value="1800" />
<entry key="DB_HOST" value="20.249.197.193" /> <!-- LoadBalancer External IP 사용 -->
<entry key="DB_NAME" value="tripgen_user_db" />
<entry key="DB_PASSWORD" value="tripgen_user_123" />
<entry key="DB_PORT" value="5432" />
<entry key="DB_USERNAME" value="tripgen_user" />
<entry key="FILE_BASE_URL" value="http://localhost:8081" />
<entry key="FILE_MAX_SIZE" value="5242880" />
<entry key="FILE_UPLOAD_PATH" value="/app/uploads" />
<entry key="JPA_DDL_AUTO" value="update" />
<entry key="JPA_SHOW_SQL" value="true" />
<entry key="JWT_ACCESS_TOKEN_EXPIRATION" value="86400" />
<entry key="JWT_REFRESH_TOKEN_EXPIRATION" value="604800" />
<entry key="JWT_SECRET" value="dev-jwt-secret-key-for-development-only" />
<entry key="LOG_LEVEL_APP" value="DEBUG" />
<entry key="LOG_LEVEL_ROOT" value="INFO" />
<entry key="LOG_LEVEL_SECURITY" value="DEBUG" />
<entry key="MAX_LOGIN_ATTEMPTS" value="5" />
<entry key="PASSWORD_MIN_LENGTH" value="8" />
<entry key="REDIS_DATABASE" value="0" />
<entry key="REDIS_HOST" value="20.214.121.28" /> <!-- Redis LoadBalancer External IP 사용 -->
<entry key="REDIS_PASSWORD" value="" />
<entry key="REDIS_PORT" value="6379" />
<entry key="SERVER_PORT" value="8081" />
<entry key="SPRING_PROFILES_ACTIVE" value="dev" />
<!-- MQ 사용하는 서비스의 경우 MQ 유형에 맞게 추가 -->
<!-- Azure Service Bus 예시 -->
<entry key="SERVICE_BUS_CONNECTION_STRING" value="Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=..." />
<!-- RabbitMQ 예시 -->
<entry key="RABBITMQ_HOST" value="20.xxx.xxx.xxx" />
<entry key="RABBITMQ_PORT" value="5672" />
<!-- Kafka 예시 -->
<entry key="KAFKA_BOOTSTRAP_SERVERS" value="20.xxx.xxx.xxx:9092" />
<!-- 기타 MQ의 경우 해당 MQ에 필요한 연결 정보를 환경변수로 추가 -->
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value="user-service:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<EXTENSION ID="com.intellij.execution.ExternalSystemRunConfigurationJavaExtension">
<extension name="net.ashald.envfile">
<option name="IS_ENABLED" value="false" />
<option name="IS_SUBST" value="false" />
<option name="IS_PATH_MACRO_SUPPORTED" value="false" />
<option name="IS_IGNORE_MISSING_FILES" value="false" />
<option name="IS_ENABLE_EXPERIMENTAL_INTEGRATIONS" value="false" />
<ENTRIES>
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
</ENTRIES>
</extension>
</EXTENSION>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
```
[참고자료]
- 데이터베이스설치결과서: develop/database/exec/db-exec-dev.md
- 각 서비스별 DB 연결 정보 (사용자명, 비밀번호, DB명)
- LoadBalancer Service External IP 목록
- 캐시설치결과서: develop/database/exec/cache-exec-dev.md
- 각 서비스별 Redis 연결 정보
- LoadBalancer Service External IP 목록
- MQ설치결과서: develop/mq/mq-exec-dev.md
- MQ 유형 및 연결 정보
- 연결에 필요한 호스트, 포트, 인증 정보
- LoadBalancer Service External IP (해당하는 경우)

418
develop/dev/dev-backend.md Normal file
View File

@ -0,0 +1,418 @@
# Meeting 서비스 백엔드 개발 결과
## 1. 개요
### 1.1 개발 범위
- **서비스명**: Meeting Service
- **포트**: 8081
- **아키텍처**: Clean/Hexagonal Architecture
- **프레임워크**: Spring Boot 3.3.0, Java 21
- **데이터베이스**: PostgreSQL (meetingdb)
- **캐시**: Redis (database: 1)
- **메시징**: Azure Event Hubs
### 1.2 개발 방식
3단계 점진적 개발:
- **Stage 0 (준비)**: 프로젝트 구조 파악 및 메인 애플리케이션 생성
- **Stage 1 (공통 모듈)**: common 모듈 검토
- **Stage 2 (서비스 구현)**: Config, Domain, Service, Gateway, Controller 레이어 구현
---
## 2. Stage 0: 준비 단계
### 2.1 완료 항목
✅ 기존 개발 결과 분석
- 62개 Java 파일 확인 (Domain, Service, UseCase, Gateway, Entity, Repository)
- Clean/Hexagonal 아키텍처 패턴 확인
- 패키지 구조 문서 작성 (develop/dev/package-structure-meeting.md)
✅ MeetingApplication.java 생성
```java
위치: meeting/src/main/java/com/unicorn/hgzero/meeting/MeetingApplication.java
패키지: com.unicorn.hgzero.meeting
ComponentScan: {"com.unicorn.hgzero.meeting", "com.unicorn.hgzero.common"}
```
✅ application.yml 확인
```yaml
서버 포트: 8081
데이터베이스: PostgreSQL (meetingdb)
Redis: database 1
JWT 설정: access-token-validity 3600초
CORS: http://localhost:*
```
✅ 컴파일 에러 수정
- TemplateEntity 패키지 경로 수정
- Dashboard 도메인 클래스 확장:
- userId, period 필드 추가
- Statistics 클래스 필드 확장 (11개 필드)
- 도메인 메서드 추가:
- MinutesSection.update(String title, String content)
- Todo.update(String title, String description, String assigneeId, LocalDate dueDate, String priority)
- Minutes.incrementVersion()
- Minutes.updateTitle(String title)
### 2.2 컴파일 결과
```
BUILD SUCCESSFUL
경고: 1개 (MinutesEntity @Builder.Default)
에러: 0개
```
---
## 3. Stage 1: common 모듈
### 3.1 common 모듈 구성
✅ 검토 완료
| 카테고리 | 클래스 | 설명 |
|---------|--------|------|
| AOP | LoggingAspect | 로깅 관점 |
| Config | JpaConfig | JPA 설정 |
| DTO | ApiResponse | API 응답 포맷 |
| DTO | JwtTokenDTO, JwtTokenRefreshDTO, JwtTokenVerifyDTO | JWT 토큰 DTO |
| Entity | BaseTimeEntity | 생성/수정 시간 베이스 엔티티 |
| Exception | BusinessException | 비즈니스 예외 |
| Exception | ErrorCode | 에러 코드 |
| Exception | InfraException | 인프라 예외 |
| Util | DateUtil | 날짜 유틸리티 |
| Util | StringUtil | 문자열 유틸리티 |
### 3.2 컴파일 결과
```
BUILD SUCCESSFUL
```
---
## 4. Stage 2: meeting 서비스 구현
### 4.1 Config 레이어 (완료)
#### 4.1.1 SecurityConfig
✅ 구현 완료
```
위치: infra/config/SecurityConfig.java
기능:
- JWT 기반 인증
- CORS 설정 (환경변수 기반)
- Stateless 세션 관리
- 공개 엔드포인트: /actuator/**, /swagger-ui/**, /health, /ws/**
- WebSocket 엔드포인트 허용
```
#### 4.1.2 JWT 인증 시스템
✅ 구현 완료
```
위치: infra/config/jwt/
JwtTokenProvider:
- JWT 토큰 검증 및 파싱
- 사용자 정보 추출 (userId, username, authority)
- 토큰 만료 확인
JwtAuthenticationFilter:
- HTTP 요청에서 JWT 토큰 추출
- Spring Security 인증 컨텍스트 설정
- 공개 엔드포인트 필터 제외
UserPrincipal:
- 인증된 사용자 정보 객체
- userId, username, authority 필드
- 권한 확인 메서드 (isAdmin, isUser)
```
#### 4.1.3 SwaggerConfig
✅ 구현 완료
```
위치: infra/config/SwaggerConfig.java
기능:
- OpenAPI 3.0 설정
- Bearer JWT 인증 스킴
- 서버 설정 (localhost:8081, 커스텀 서버)
- API 정보 (제목, 설명, 버전, 연락처)
```
### 4.2 컴파일 결과
```
BUILD SUCCESSFUL
경고: 1개 (deprecated API 사용)
에러: 0개
```
---
## 5. 기존 구현 현황
### 5.1 Domain 레이어 (6개 클래스)
✅ 기존 구현 확인
- Meeting: 회의 도메인
- Minutes: 회의록 도메인 (updateTitle, incrementVersion 메서드 추가)
- MinutesSection: 회의록 섹션 도메인 (update 메서드 추가)
- Todo: Todo 도메인 (update 메서드 추가)
- Template: 템플릿 도메인
- Dashboard: 대시보드 도메인 (userId, period 필드 추가, Statistics 확장)
### 5.2 Service 레이어 (6개 클래스)
✅ 기존 구현 확인
- MeetingService: 회의 비즈니스 로직
- MinutesService: 회의록 비즈니스 로직
- MinutesSectionService: 회의록 섹션 비즈니스 로직
- TodoService: Todo 비즈니스 로직
- TemplateService: 템플릿 비즈니스 로직
- DashboardService: 대시보드 비즈니스 로직
### 5.3 UseCase 레이어 (28개 인터페이스)
✅ 기존 구현 확인
- UseCase In (16개): Service 입력 포트
- UseCase Out (12개): Gateway 출력 포트
### 5.4 Gateway 레이어 (6개 클래스)
✅ 기존 구현 확인
- MeetingGateway: 회의 게이트웨이
- MinutesGateway: 회의록 게이트웨이
- TodoGateway: Todo 게이트웨이
- TemplateGateway: 템플릿 게이트웨이
- DashboardGateway: 대시보드 게이트웨이
- CacheGateway: 캐시 게이트웨이
### 5.5 Entity 레이어 (5개 클래스)
✅ 기존 구현 확인
- MeetingEntity: 회의 엔티티
- MinutesEntity: 회의록 엔티티
- MinutesSectionEntity: 회의록 섹션 엔티티 (package 수정)
- TodoEntity: Todo 엔티티
- TemplateEntity: 템플릿 엔티티 (package 수정, import 추가)
### 5.6 Repository 레이어 (5개 인터페이스)
✅ 기존 구현 확인
- MeetingJpaRepository
- MinutesJpaRepository
- TodoJpaRepository
- TemplateJpaRepository
- MinutesSectionJpaRepository
---
## 6. 추가 구현 필요 항목
### 6.1 Controller 레이어 (5개 클래스)
⏳ 구현 필요
- DashboardController (GET /dashboard)
- MeetingController (POST, PUT, POST /meetings 관련 4개 API)
- MinutesController (GET, PATCH, POST, DELETE /minutes 관련 7개 API)
- TodoController (POST, PATCH /todos 관련 2개 API)
- TemplateController (GET /templates 관련 2개 API)
### 6.2 DTO 레이어 (~20개 클래스)
⏳ 구현 필요
- Request DTOs (~10개): 각 API의 요청 DTO
- Response DTOs (~10개): 각 API의 응답 DTO
### 6.3 WebSocket 레이어 (3개 클래스)
⏳ 구현 필요
- WebSocketConfig: WebSocket 설정
- WebSocketHandler: WebSocket 메시지 핸들러
- CollaborationMessage: 실시간 협업 메시지
### 6.4 Event 레이어 (6개 클래스)
⏳ 구현 필요
- Event Publishers (3개):
- MeetingEventPublisher
- MinutesEventPublisher
- TodoEventPublisher
- Event Messages (3개):
- MeetingStartedEvent
- MeetingEndedEvent
- NotificationRequestEvent
### 6.5 Cache 레이어 (2개 클래스)
⏳ 구현 필요
- CacheService: 캐시 서비스 구현체
- CacheKeyGenerator: 캐시 키 생성기
### 6.6 추가 Config (2개 클래스)
⏳ 구현 필요
- RedisConfig: Redis 설정
- WebSocketConfig: WebSocket 설정
---
## 7. 다음 단계 계획
### 7.1 Controller 및 DTO 구현
우선순위: 높음
1. **DashboardController + DTO**
- GET /dashboard
- DashboardResponse
2. **MeetingController + DTOs**
- POST /meetings (CreateMeetingRequest/Response)
- PUT /meetings/{id}/template (SelectTemplateRequest/Response)
- POST /meetings/{id}/start (StartMeetingRequest/Response)
- POST /meetings/{id}/end (EndMeetingRequest/Response)
3. **MinutesController + DTOs**
- 7개 API + Request/Response DTOs
4. **TodoController + DTOs**
- 2개 API + Request/Response DTOs
5. **TemplateController + DTOs**
- 2개 API + Response DTOs
### 7.2 WebSocket 구현
우선순위: 중간
- WebSocketConfig
- WebSocketHandler
- CollaborationMessage
### 7.3 Event 및 Cache 구현
우선순위: 중간
- Event Publishers
- Event Messages
- Cache Service
- Redis Config
### 7.4 통합 테스트
우선순위: 높음
- 전체 빌드 (./gradlew meeting:build)
- API 통합 테스트
- WebSocket 연결 테스트
---
## 8. 개발 환경
### 8.1 기술 스택
- **언어**: Java 21
- **프레임워크**: Spring Boot 3.3.0
- **빌드 도구**: Gradle 8.14
- **데이터베이스**: PostgreSQL 14
- **캐시**: Redis 7
- **메시징**: Azure Event Hubs
- **API 문서**: OpenAPI 3.0 (Swagger)
### 8.2 의존성
```gradle
Spring Boot Starter Web
Spring Boot Starter Data JPA
Spring Boot Starter Security
Spring Boot Starter WebSocket
Spring Boot Starter Data Redis
Spring Boot Starter Actuator
SpringDoc OpenAPI (2.5.0)
JJWT (0.12.5)
Lombok
PostgreSQL Driver
```
### 8.3 데이터베이스 연결 정보
```yaml
호스트: 4.230.48.72
포트: 5432
데이터베이스: meetingdb
사용자: hgzerouser
```
### 8.4 Redis 연결 정보
```yaml
호스트: 20.249.177.114
포트: 6379
데이터베이스: 1
```
---
## 9. 컴파일 및 빌드
### 9.1 컴파일 명령
```bash
# Meeting 서비스 컴파일
./gradlew meeting:compileJava
# Common 모듈 컴파일
./gradlew common:compileJava
# 전체 프로젝트 컴파일
./gradlew compileJava
```
### 9.2 빌드 명령
```bash
# Meeting 서비스 빌드
./gradlew meeting:build
# 전체 프로젝트 빌드
./gradlew build
```
### 9.3 실행 명령
```bash
# Meeting 서비스 실행
./gradlew meeting:bootRun
# 또는 jar 실행
java -jar meeting/build/libs/meeting.jar
```
---
## 10. API 엔드포인트
### 10.1 Dashboard APIs (1개)
| Method | Endpoint | 설명 | 상태 |
|--------|----------|------|-----|
| GET | /dashboard | 대시보드 데이터 조회 | ⏳ 미구현 |
### 10.2 Meeting APIs (4개)
| Method | Endpoint | 설명 | 상태 |
|--------|----------|------|-----|
| POST | /meetings | 회의 예약 | ⏳ 미구현 |
| PUT | /meetings/{meetingId}/template | 템플릿 선택 | ⏳ 미구현 |
| POST | /meetings/{meetingId}/start | 회의 시작 | ⏳ 미구현 |
| POST | /meetings/{meetingId}/end | 회의 종료 | ⏳ 미구현 |
### 10.3 Minutes APIs (7개)
| Method | Endpoint | 설명 | 상태 |
|--------|----------|------|-----|
| GET | /minutes | 회의록 목록 조회 | ⏳ 미구현 |
| GET | /minutes/{minutesId} | 회의록 상세 조회 | ⏳ 미구현 |
| PATCH | /minutes/{minutesId} | 회의록 수정 | ⏳ 미구현 |
| POST | /minutes/{minutesId}/finalize | 회의록 확정 | ⏳ 미구현 |
| POST | /minutes/{minutesId}/sections/{sectionId}/verify | 섹션 검증 완료 | ⏳ 미구현 |
| POST | /minutes/{minutesId}/sections/{sectionId}/lock | 섹션 잠금 | ⏳ 미구현 |
| DELETE | /minutes/{minutesId}/sections/{sectionId}/lock | 섹션 잠금 해제 | ⏳ 미구현 |
### 10.4 Todo APIs (2개)
| Method | Endpoint | 설명 | 상태 |
|--------|----------|------|-----|
| POST | /todos | Todo 할당 | ⏳ 미구현 |
| PATCH | /todos/{todoId}/complete | Todo 완료 | ⏳ 미구현 |
### 10.5 Template APIs (2개)
| Method | Endpoint | 설명 | 상태 |
|--------|----------|------|-----|
| GET | /templates | 템플릿 목록 조회 | ⏳ 미구현 |
| GET | /templates/{templateId} | 템플릿 상세 조회 | ⏳ 미구현 |
### 10.6 WebSocket
| Endpoint | 설명 | 상태 |
|----------|------|-----|
| GET /ws/minutes/{minutesId} | 회의록 실시간 협업 | ⏳ 미구현 |
---
## 11. 참고 문서
- 패키지 구조도: develop/dev/package-structure-meeting.md
- API 설계서: design/backend/api/API설계서.md
- 논리 아키텍처: design/backend/logical/logical-architecture.md
- 내부 시퀀스: design/backend/sequence/inner/*.puml
- 데이터베이스 설치 결과: develop/database/exec/db-exec-dev.md

View File

@ -0,0 +1,312 @@
# Meeting Service 패키지 구조도
## 전체 구조
```
meeting/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/unicorn/hgzero/meeting/
│ │ │ ├── MeetingApplication.java
│ │ │ ├── biz/
│ │ │ │ ├── domain/
│ │ │ │ │ ├── Dashboard.java (✅ 기존)
│ │ │ │ │ ├── Meeting.java (✅ 기존)
│ │ │ │ │ ├── Minutes.java (✅ 기존)
│ │ │ │ │ ├── MinutesSection.java (✅ 기존)
│ │ │ │ │ ├── Template.java (✅ 기존)
│ │ │ │ │ ├── Todo.java (✅ 기존)
│ │ │ │ │ ├── Session.java
│ │ │ │ │ ├── Participant.java
│ │ │ │ │ ├── CollaborationEvent.java
│ │ │ │ │ └── Statistics.java
│ │ │ │ ├── service/
│ │ │ │ │ ├── DashboardService.java (✅ 기존)
│ │ │ │ │ ├── MeetingService.java (✅ 기존)
│ │ │ │ │ ├── MinutesService.java (✅ 기존)
│ │ │ │ │ ├── MinutesSectionService.java (✅ 기존)
│ │ │ │ │ ├── TemplateService.java (✅ 기존)
│ │ │ │ │ ├── TodoService.java (✅ 기존)
│ │ │ │ │ ├── SessionService.java
│ │ │ │ │ ├── CollaborationService.java
│ │ │ │ │ └── StatisticsService.java
│ │ │ │ ├── usecase/
│ │ │ │ │ ├── in/
│ │ │ │ │ │ ├── dashboard/
│ │ │ │ │ │ │ └── GetDashboardUseCase.java (✅ 기존)
│ │ │ │ │ │ ├── meeting/
│ │ │ │ │ │ │ ├── CreateMeetingUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── StartMeetingUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── EndMeetingUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── GetMeetingUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── CancelMeetingUseCase.java (✅ 기존)
│ │ │ │ │ │ │ └── SelectTemplateUseCase.java
│ │ │ │ │ │ ├── minutes/
│ │ │ │ │ │ │ ├── CreateMinutesUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── GetMinutesUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── UpdateMinutesUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── FinalizeMinutesUseCase.java (✅ 기존)
│ │ │ │ │ │ │ └── GetMinutesListUseCase.java
│ │ │ │ │ │ ├── section/
│ │ │ │ │ │ │ ├── CreateSectionUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── GetSectionUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── UpdateSectionUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── DeleteSectionUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── VerifySectionUseCase.java (✅ 기존)
│ │ │ │ │ │ │ └── LockSectionUseCase.java (✅ 기존)
│ │ │ │ │ │ ├── template/
│ │ │ │ │ │ │ ├── CreateTemplateUseCase.java (✅ 기존)
│ │ │ │ │ │ │ ├── GetTemplateUseCase.java (✅ 기존)
│ │ │ │ │ │ │ └── GetTemplateListUseCase.java
│ │ │ │ │ │ └── todo/
│ │ │ │ │ │ ├── CreateTodoUseCase.java (✅ 기존)
│ │ │ │ │ │ ├── GetTodoUseCase.java (✅ 기존)
│ │ │ │ │ │ ├── UpdateTodoUseCase.java (✅ 기존)
│ │ │ │ │ │ ├── CompleteTodoUseCase.java (✅ 기존)
│ │ │ │ │ │ └── CancelTodoUseCase.java (✅ 기존)
│ │ │ │ │ └── out/
│ │ │ │ │ ├── DashboardReader.java (✅ 기존)
│ │ │ │ │ ├── MeetingReader.java (✅ 기존)
│ │ │ │ │ ├── MeetingWriter.java (✅ 기존)
│ │ │ │ │ ├── MinutesReader.java (✅ 기존)
│ │ │ │ │ ├── MinutesWriter.java (✅ 기존)
│ │ │ │ │ ├── MinutesSectionReader.java (✅ 기존)
│ │ │ │ │ ├── MinutesSectionWriter.java (✅ 기존)
│ │ │ │ │ ├── TemplateReader.java (✅ 기존)
│ │ │ │ │ ├── TemplateWriter.java (✅ 기존)
│ │ │ │ │ ├── TodoReader.java (✅ 기존)
│ │ │ │ │ ├── TodoWriter.java (✅ 기존)
│ │ │ │ │ ├── SessionReader.java
│ │ │ │ │ ├── SessionWriter.java
│ │ │ │ │ ├── ParticipantReader.java
│ │ │ │ │ ├── ParticipantWriter.java
│ │ │ │ │ ├── CollaborationEventWriter.java
│ │ │ │ │ └── StatisticsWriter.java
│ │ │ │ └── dto/
│ │ │ │ ├── DashboardDTO.java
│ │ │ │ ├── MeetingDTO.java
│ │ │ │ ├── MinutesDTO.java
│ │ │ │ ├── SectionDTO.java
│ │ │ │ ├── TemplateDTO.java
│ │ │ │ └── TodoDTO.java
│ │ │ └── infra/
│ │ │ ├── controller/
│ │ │ │ ├── DashboardController.java
│ │ │ │ ├── MeetingController.java
│ │ │ │ ├── MinutesController.java
│ │ │ │ ├── TemplateController.java
│ │ │ │ └── TodoController.java
│ │ │ ├── dto/
│ │ │ │ ├── request/
│ │ │ │ │ ├── CreateMeetingRequest.java
│ │ │ │ │ ├── UpdateMeetingRequest.java
│ │ │ │ │ ├── CreateMinutesRequest.java
│ │ │ │ │ ├── UpdateMinutesRequest.java
│ │ │ │ │ ├── CreateSectionRequest.java
│ │ │ │ │ ├── UpdateSectionRequest.java
│ │ │ │ │ ├── CreateTodoRequest.java
│ │ │ │ │ ├── UpdateTodoRequest.java
│ │ │ │ │ ├── CreateTemplateRequest.java
│ │ │ │ │ └── SelectTemplateRequest.java
│ │ │ │ └── response/
│ │ │ │ ├── DashboardResponse.java
│ │ │ │ ├── MeetingResponse.java
│ │ │ │ ├── SessionResponse.java
│ │ │ │ ├── MeetingEndResponse.java
│ │ │ │ ├── MinutesResponse.java
│ │ │ │ ├── MinutesDetailResponse.java
│ │ │ │ ├── MinutesListResponse.java
│ │ │ │ ├── SectionResponse.java
│ │ │ │ ├── TemplateResponse.java
│ │ │ │ ├── TemplateListResponse.java
│ │ │ │ └── TodoResponse.java
│ │ │ ├── gateway/
│ │ │ │ ├── DashboardGateway.java (✅ 기존)
│ │ │ │ ├── MeetingGateway.java (✅ 기존)
│ │ │ │ ├── MinutesGateway.java (✅ 기존)
│ │ │ │ ├── MinutesSectionGateway.java (✅ 기존)
│ │ │ │ ├── TemplateGateway.java (✅ 기존)
│ │ │ │ ├── TodoGateway.java (✅ 기존)
│ │ │ │ ├── SessionGateway.java
│ │ │ │ ├── ParticipantGateway.java
│ │ │ │ ├── CollaborationEventGateway.java
│ │ │ │ ├── StatisticsGateway.java
│ │ │ │ └── entity/
│ │ │ │ ├── MeetingEntity.java (✅ 기존)
│ │ │ │ ├── MinutesEntity.java (✅ 기존)
│ │ │ │ ├── MinutesSectionEntity.java (✅ 기존)
│ │ │ │ ├── TemplateEntity.java (✅ 기존)
│ │ │ │ ├── TodoEntity.java (✅ 기존)
│ │ │ │ ├── SessionEntity.java
│ │ │ │ ├── ParticipantEntity.java
│ │ │ │ ├── CollaborationEventEntity.java
│ │ │ │ └── StatisticsEntity.java
│ │ │ │ └── repository/
│ │ │ │ ├── MeetingJpaRepository.java (✅ 기존)
│ │ │ │ ├── MinutesJpaRepository.java (✅ 기존)
│ │ │ │ ├── MinutesSectionJpaRepository.java (✅ 기존)
│ │ │ │ ├── TemplateJpaRepository.java (✅ 기존)
│ │ │ │ ├── TodoJpaRepository.java (✅ 기존)
│ │ │ │ ├── SessionJpaRepository.java
│ │ │ │ ├── ParticipantJpaRepository.java
│ │ │ │ ├── CollaborationEventJpaRepository.java
│ │ │ │ └── StatisticsJpaRepository.java
│ │ │ ├── websocket/
│ │ │ │ ├── WebSocketConfig.java
│ │ │ │ ├── WebSocketHandler.java
│ │ │ │ └── CollaborationMessageHandler.java
│ │ │ ├── event/
│ │ │ │ ├── publisher/
│ │ │ │ │ ├── EventPublisher.java
│ │ │ │ │ └── EventHubPublisher.java
│ │ │ │ └── dto/
│ │ │ │ ├── MeetingStartedEvent.java
│ │ │ │ ├── MeetingEndedEvent.java
│ │ │ │ ├── TodoAssignedEvent.java
│ │ │ │ └── NotificationRequestEvent.java
│ │ │ ├── cache/
│ │ │ │ ├── CacheConfig.java
│ │ │ │ └── CacheService.java
│ │ │ └── config/
│ │ │ ├── SecurityConfig.java
│ │ │ ├── SwaggerConfig.java
│ │ │ ├── JpaConfig.java
│ │ │ └── jwt/
│ │ │ ├── JwtAuthenticationFilter.java
│ │ │ ├── JwtTokenProvider.java
│ │ │ └── UserPrincipal.java
│ │ └── resources/
│ │ ├── application.yml (✅ 기존)
│ │ └── application-dev.yml
│ └── test/
│ └── java/
│ └── com/unicorn/hgzero/meeting/
│ └── (테스트 코드는 제외)
└── build.gradle (✅ 기존)
```
## 패키지별 역할
### 1. biz (비즈니스 로직 레이어)
#### 1.1 domain
- **역할**: 비즈니스 도메인 모델
- **파일**:
- Dashboard, Meeting, Minutes, MinutesSection, Template, Todo (✅ 기존)
- Session, Participant, CollaborationEvent, Statistics (신규 필요)
#### 1.2 service
- **역할**: 비즈니스 로직 구현 (UseCase 구현체)
- **파일**:
- DashboardService, MeetingService, MinutesService, MinutesSectionService, TemplateService, TodoService (✅ 기존)
- SessionService, CollaborationService, StatisticsService (신규 필요)
#### 1.3 usecase/in
- **역할**: Input Port (애플리케이션 서비스 인터페이스)
- **파일**: 16개 기존, 3개 신규 필요
- SelectTemplateUseCase
- GetMinutesListUseCase
- GetTemplateListUseCase
#### 1.4 usecase/out
- **역할**: Output Port (Repository 인터페이스)
- **파일**: 12개 기존, 7개 신규 필요
- SessionReader/Writer
- ParticipantReader/Writer
- CollaborationEventWriter
- StatisticsWriter
#### 1.5 dto
- **역할**: 비즈니스 레이어 DTO
- **파일**: 전부 신규 필요 (6개)
### 2. infra (인프라 레이어)
#### 2.1 controller
- **역할**: REST API 엔드포인트
- **파일**: 전부 신규 필요 (5개)
#### 2.2 dto/request & dto/response
- **역할**: API 요청/응답 DTO
- **파일**: 전부 신규 필요 (약 20개)
#### 2.3 gateway
- **역할**: Repository 구현체 (Adapter)
- **파일**:
- 6개 Gateway 기존, 4개 신규 필요
- 5개 Entity 기존, 4개 신규 필요
- 5개 Repository 기존, 4개 신규 필요
#### 2.4 websocket
- **역할**: 실시간 협업 WebSocket 처리
- **파일**: 전부 신규 필요 (3개)
#### 2.5 event
- **역할**: 이벤트 발행
- **파일**: 전부 신규 필요 (6개)
#### 2.6 cache
- **역할**: Redis 캐시 처리
- **파일**: 전부 신규 필요 (2개)
#### 2.7 config
- **역할**: Spring 설정
- **파일**: 전부 신규 필요 (7개)
## 구현 상태 요약
### ✅ 구현 완료 (62개 파일)
- Domain: 6개
- Service: 6개
- UseCase In: 16개
- UseCase Out: 12개
- Gateway: 6개
- Entity: 5개
- Repository: 5개
- Application Config: 1개 (application.yml)
- Build Config: 1개 (build.gradle)
- Main Class: 0개
### ❌ 구현 필요 (약 80개 파일)
- Main Application: 1개
- Domain: 4개
- Service: 3개
- UseCase In: 3개
- UseCase Out: 7개
- Business DTO: 6개
- Controller: 5개
- API DTO: ~20개
- Gateway: 4개
- Entity: 4개
- Repository: 4개
- WebSocket: 3개
- Event: 6개
- Cache: 2개
- Config: 7개
- Application Config: 1개 (application-dev.yml)
## 다음 단계
1. **0단계: 준비**
- ✅ 패키지 구조도 작성 완료
- Application 메인 클래스 작성
- application-dev.yml 작성
2. **1단계: common 모듈 확인 및 보완**
- common 모듈 검토
- 필요한 공통 클래스 추가
3. **2단계: meeting 서비스 구현**
- Config 레이어 (SecurityConfig, SwaggerConfig, JWT 등)
- Event 발행 인프라
- Cache 서비스
- Controller 레이어
- DTO 레이어
- 누락된 Domain/Service/Gateway
- WebSocket 실시간 협업
4. **3단계: 컴파일 및 검증**
- 각 단계별 컴파일
- 에러 수정
- 최종 빌드

View File

@ -0,0 +1,18 @@
package com.unicorn.hgzero.meeting;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.ComponentScan;
/**
* Meeting Service Application
* 회의, 회의록, Todo, 실시간 협업 관리 서비스 메인 클래스
*/
@SpringBootApplication
@ComponentScan(basePackages = {"com.unicorn.hgzero.meeting", "com.unicorn.hgzero.common"})
public class MeetingApplication {
public static void main(String[] args) {
SpringApplication.run(MeetingApplication.class, args);
}
}

View File

@ -16,6 +16,16 @@ import java.util.List;
@AllArgsConstructor @AllArgsConstructor
public class Dashboard { public class Dashboard {
/**
* 사용자 ID
*/
private String userId;
/**
* 조회 기간
*/
private String period;
/** /**
* 다가오는 회의 목록 * 다가오는 회의 목록
*/ */
@ -49,6 +59,11 @@ public class Dashboard {
*/ */
private Integer totalMeetings; private Integer totalMeetings;
/**
* 예정된 회의
*/
private Integer scheduledMeetings;
/** /**
* 진행 중인 회의 * 진행 중인 회의
*/ */
@ -59,11 +74,31 @@ public class Dashboard {
*/ */
private Integer completedMeetings; private Integer completedMeetings;
/**
* 전체 회의록
*/
private Integer totalMinutes;
/**
* 초안 상태 회의록
*/
private Integer draftMinutes;
/**
* 확정된 회의록
*/
private Integer finalizedMinutes;
/** /**
* 전체 Todo * 전체 Todo
*/ */
private Integer totalTodos; private Integer totalTodos;
/**
* 대기 중인 Todo
*/
private Integer pendingTodos;
/** /**
* 완료된 Todo * 완료된 Todo
*/ */

View File

@ -84,4 +84,19 @@ public class Minutes {
public boolean isFinalized() { public boolean isFinalized() {
return "FINALIZED".equals(this.status); return "FINALIZED".equals(this.status);
} }
/**
* 버전 증가
*/
public void incrementVersion() {
this.version++;
}
/**
* 회의록 제목 업데이트
*/
public void updateTitle(String title) {
this.title = title;
this.version++;
}
} }

View File

@ -95,4 +95,12 @@ public class MinutesSection {
public boolean isVerified() { public boolean isVerified() {
return Boolean.TRUE.equals(this.verified); return Boolean.TRUE.equals(this.verified);
} }
/**
* 섹션 정보 업데이트
*/
public void update(String title, String content) {
this.title = title;
this.content = content;
}
} }

View File

@ -97,4 +97,15 @@ public class Todo {
LocalDate.now().isAfter(this.dueDate) && LocalDate.now().isAfter(this.dueDate) &&
!isCompleted(); !isCompleted();
} }
/**
* Todo 정보 업데이트
*/
public void update(String title, String description, String assigneeId, LocalDate dueDate, String priority) {
this.title = title;
this.description = description;
this.assigneeId = assigneeId;
this.dueDate = dueDate;
this.priority = priority;
}
} }

View File

@ -76,7 +76,7 @@ public class MinutesService implements
} }
// 제목 수정 // 제목 수정
minutes.update(title, minutes.getSections()); minutes.updateTitle(title);
// 저장 // 저장
Minutes updatedMinutes = minutesWriter.save(minutes); Minutes updatedMinutes = minutesWriter.save(minutes);

View File

@ -0,0 +1,85 @@
package com.unicorn.hgzero.meeting.infra.config;
import com.unicorn.hgzero.meeting.infra.config.jwt.JwtAuthenticationFilter;
import com.unicorn.hgzero.meeting.infra.config.jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
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.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
/**
* Spring Security 설정
* JWT 기반 인증 API 보안 설정
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Value("${cors.allowed-origins:http://localhost:3000,http://localhost:8080,http://localhost:8081,http://localhost:8082,http://localhost:8083,http://localhost:8084}")
private String allowedOrigins;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// Actuator endpoints
.requestMatchers("/actuator/**").permitAll()
// Swagger UI endpoints
.requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-resources/**", "/webjars/**").permitAll()
// Health check
.requestMatchers("/health").permitAll()
// WebSocket endpoints
.requestMatchers("/ws/**").permitAll()
// All other requests require authentication
.anyRequest().authenticated()
)
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// 환경변수에서 허용할 Origin 패턴 설정
String[] origins = allowedOrigins.split(",");
configuration.setAllowedOriginPatterns(Arrays.asList(origins));
// 허용할 HTTP 메소드
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
// 허용할 헤더
configuration.setAllowedHeaders(Arrays.asList(
"Authorization", "Content-Type", "X-Requested-With", "Accept",
"Origin", "Access-Control-Request-Method", "Access-Control-Request-Headers"
));
// 자격 증명 허용
configuration.setAllowCredentials(true);
// Pre-flight 요청 캐시 시간
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}

View File

@ -0,0 +1,63 @@
package com.unicorn.hgzero.meeting.infra.config;
import io.swagger.v3.oas.models.Components;
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.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger/OpenAPI 설정
* Meeting Service API 문서화를 위한 설정
*/
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(apiInfo())
.addServersItem(new Server()
.url("http://localhost:8081")
.description("Local Development"))
.addServersItem(new Server()
.url("{protocol}://{host}:{port}")
.description("Custom Server")
.variables(new io.swagger.v3.oas.models.servers.ServerVariables()
.addServerVariable("protocol", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("http")
.description("Protocol (http or https)")
.addEnumItem("http")
.addEnumItem("https"))
.addServerVariable("host", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("localhost")
.description("Server host"))
.addServerVariable("port", new io.swagger.v3.oas.models.servers.ServerVariable()
._default("8081")
.description("Server port"))))
.addSecurityItem(new SecurityRequirement().addList("Bearer Authentication"))
.components(new Components()
.addSecuritySchemes("Bearer Authentication", createAPIKeyScheme()));
}
private Info apiInfo() {
return new Info()
.title("Meeting Service API")
.description("회의, 회의록, Todo, 실시간 협업 관리 API")
.version("1.0.0")
.contact(new Contact()
.name("HGZero Development Team")
.email("dev@hgzero.com"));
}
private SecurityScheme createAPIKeyScheme() {
return new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.bearerFormat("JWT")
.scheme("bearer");
}
}

View File

@ -0,0 +1,87 @@
package com.unicorn.hgzero.meeting.infra.config.jwt;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Collections;
/**
* JWT 인증 필터
* HTTP 요청에서 JWT 토큰을 추출하여 인증을 수행
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserId(token);
String username = null;
String authority = null;
try {
username = jwtTokenProvider.getUsername(token);
} catch (Exception e) {
log.debug("JWT에 username 클레임이 없음: {}", e.getMessage());
}
try {
authority = jwtTokenProvider.getAuthority(token);
} catch (Exception e) {
log.debug("JWT에 authority 클레임이 없음: {}", e.getMessage());
}
if (StringUtils.hasText(userId)) {
// UserPrincipal 객체 생성 (username과 authority가 없어도 동작)
UserPrincipal userPrincipal = UserPrincipal.builder()
.userId(userId)
.username(username != null ? username : "unknown")
.authority(authority != null ? authority : "USER")
.build();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userPrincipal,
null,
Collections.singletonList(new SimpleGrantedAuthority(authority != null ? authority : "USER"))
);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("인증된 사용자: {} ({})", userPrincipal.getUsername(), userId);
}
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/actuator") ||
path.startsWith("/swagger-ui") ||
path.startsWith("/v3/api-docs") ||
path.equals("/health") ||
path.startsWith("/ws");
}
}

View File

@ -0,0 +1,138 @@
package com.unicorn.hgzero.meeting.infra.config.jwt;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SecurityException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 토큰 제공자
* JWT 토큰의 생성, 검증, 파싱을 담당
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
private final long tokenValidityInMilliseconds;
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity:3600}") long tokenValidityInSeconds) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
/**
* HTTP 요청에서 JWT 토큰 추출
*/
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
/**
* JWT 토큰 유효성 검증
*/
public boolean validateToken(String token) {
try {
Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.debug("Invalid JWT signature: {}", e.getMessage());
} catch (ExpiredJwtException e) {
log.debug("Expired JWT token: {}", e.getMessage());
} catch (UnsupportedJwtException e) {
log.debug("Unsupported JWT token: {}", e.getMessage());
} catch (IllegalArgumentException e) {
log.debug("JWT token compact of handler are invalid: {}", e.getMessage());
}
return false;
}
/**
* JWT 토큰에서 사용자 ID 추출
*/
public String getUserId(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
/**
* JWT 토큰에서 사용자명 추출
*/
public String getUsername(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("username", String.class);
}
/**
* JWT 토큰에서 권한 정보 추출
*/
public String getAuthority(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.get("authority", String.class);
}
/**
* 토큰 만료 시간 확인
*/
public boolean isTokenExpired(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getExpiration().before(new Date());
} catch (Exception e) {
return true;
}
}
/**
* 토큰에서 만료 시간 추출
*/
public Date getExpirationDate(String token) {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getExpiration();
}
}

View File

@ -0,0 +1,51 @@
package com.unicorn.hgzero.meeting.infra.config.jwt;
import lombok.Builder;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 인증된 사용자 정보
* JWT 토큰에서 추출된 사용자 정보를 담는 Principal 객체
*/
@Getter
@Builder
@RequiredArgsConstructor
public class UserPrincipal {
/**
* 사용자 고유 ID
*/
private final String userId;
/**
* 사용자명
*/
private final String username;
/**
* 사용자 권한
*/
private final String authority;
/**
* 사용자 ID 반환 (별칭)
*/
public String getName() {
return userId;
}
/**
* 관리자 권한 여부 확인
*/
public boolean isAdmin() {
return "ADMIN".equals(authority);
}
/**
* 일반 사용자 권한 여부 확인
*/
public boolean isUser() {
return "USER".equals(authority) || authority == null;
}
}

View File

@ -55,19 +55,19 @@ public class DashboardGateway implements DashboardReader {
.count(); .count();
// 통계 객체 생성 // 통계 객체 생성
Dashboard.Statistics statistics = new Dashboard.Statistics( Dashboard.Statistics statistics = Dashboard.Statistics.builder()
totalMeetings, .totalMeetings((int) totalMeetings)
scheduledMeetings, .scheduledMeetings((int) scheduledMeetings)
inProgressMeetings, .inProgressMeetings((int) inProgressMeetings)
completedMeetings, .completedMeetings((int) completedMeetings)
totalMinutes, .totalMinutes((int) totalMinutes)
draftMinutes, .draftMinutes((int) draftMinutes)
finalizedMinutes, .finalizedMinutes((int) finalizedMinutes)
totalTodos, .totalTodos((int) totalTodos)
pendingTodos, .pendingTodos((int) pendingTodos)
completedTodos, .completedTodos((int) completedTodos)
overdueTodos .overdueTodos((int) overdueTodos)
); .build();
// 대시보드 생성 // 대시보드 생성
return Dashboard.builder() return Dashboard.builder()
@ -121,19 +121,19 @@ public class DashboardGateway implements DashboardReader {
.count(); .count();
// 통계 객체 생성 // 통계 객체 생성
Dashboard.Statistics statistics = new Dashboard.Statistics( Dashboard.Statistics statistics = Dashboard.Statistics.builder()
totalMeetings, .totalMeetings((int) totalMeetings)
scheduledMeetings, .scheduledMeetings((int) scheduledMeetings)
inProgressMeetings, .inProgressMeetings((int) inProgressMeetings)
completedMeetings, .completedMeetings((int) completedMeetings)
totalMinutes, .totalMinutes((int) totalMinutes)
draftMinutes, .draftMinutes((int) draftMinutes)
finalizedMinutes, .finalizedMinutes((int) finalizedMinutes)
totalTodos, .totalTodos((int) totalTodos)
pendingTodos, .pendingTodos((int) pendingTodos)
completedTodos, .completedTodos((int) completedTodos)
overdueTodos .overdueTodos((int) overdueTodos)
); .build();
// 대시보드 생성 // 대시보드 생성
return Dashboard.builder() return Dashboard.builder()

View File

@ -1,6 +1,7 @@
package com.unicorn.hgzero.meeting.biz.domain; package com.unicorn.hgzero.meeting.infra.gateway.entity;
import com.unicorn.hgzero.common.entity.BaseTimeEntity; import com.unicorn.hgzero.common.entity.BaseTimeEntity;
import com.unicorn.hgzero.meeting.biz.domain.Template;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder; import lombok.Builder;