From 7e06bb412f892b369b1624542fe69eb1669d142a Mon Sep 17 00:00:00 2001 From: djeon Date: Thu, 23 Oct 2025 17:23:52 +0900 Subject: [PATCH] add new meeting --- claude/make-run-profile.md | 175 ++++++++ develop/dev/dev-backend.md | 418 ++++++++++++++++++ develop/dev/package-structure-meeting.md | 312 +++++++++++++ .../hgzero/meeting/MeetingApplication.java | 18 + .../hgzero/meeting/biz/domain/Dashboard.java | 35 ++ .../hgzero/meeting/biz/domain/Minutes.java | 15 + .../meeting/biz/domain/MinutesSection.java | 8 + .../hgzero/meeting/biz/domain/Todo.java | 11 + .../meeting/biz/service/MinutesService.java | 2 +- .../meeting/infra/config/SecurityConfig.java | 85 ++++ .../meeting/infra/config/SwaggerConfig.java | 63 +++ .../config/jwt/JwtAuthenticationFilter.java | 87 ++++ .../infra/config/jwt/JwtTokenProvider.java | 138 ++++++ .../infra/config/jwt/UserPrincipal.java | 51 +++ .../infra/gateway/DashboardGateway.java | 52 +-- .../infra/gateway/entity/TemplateEntity.java | 3 +- 16 files changed, 1445 insertions(+), 28 deletions(-) create mode 100644 claude/make-run-profile.md create mode 100644 develop/dev/dev-backend.md create mode 100644 develop/dev/package-structure-meeting.md create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/MeetingApplication.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/SecurityConfig.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/SwaggerConfig.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/JwtAuthenticationFilter.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/JwtTokenProvider.java create mode 100644 meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/UserPrincipal.java diff --git a/claude/make-run-profile.md b/claude/make-run-profile.md new file mode 100644 index 0000000..f363a91 --- /dev/null +++ b/claude/make-run-profile.md @@ -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의 민감 정보는 기본값으로 지정하지 않고 실제 백킹서비스 정보로 지정 +- 백킹서비스 연결 확인 결과를 바탕으로 정확한 값을 지정 +- 기존에 파일이 있으면 내용을 분석하여 항목 추가/수정/삭제 + +[실행프로파일 예시] +``` + + + + + + + + true + true + + + + + false + false + + + +``` + +[참고자료] +- 데이터베이스설치결과서: 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 (해당하는 경우) diff --git a/develop/dev/dev-backend.md b/develop/dev/dev-backend.md new file mode 100644 index 0000000..f967445 --- /dev/null +++ b/develop/dev/dev-backend.md @@ -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 diff --git a/develop/dev/package-structure-meeting.md b/develop/dev/package-structure-meeting.md new file mode 100644 index 0000000..ed6476c --- /dev/null +++ b/develop/dev/package-structure-meeting.md @@ -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단계: 컴파일 및 검증** + - 각 단계별 컴파일 + - 에러 수정 + - 최종 빌드 diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/MeetingApplication.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/MeetingApplication.java new file mode 100644 index 0000000..041ade4 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/MeetingApplication.java @@ -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); + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Dashboard.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Dashboard.java index 9e6971f..7ea8421 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Dashboard.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Dashboard.java @@ -16,6 +16,16 @@ import java.util.List; @AllArgsConstructor public class Dashboard { + /** + * 사용자 ID + */ + private String userId; + + /** + * 조회 기간 + */ + private String period; + /** * 다가오는 회의 목록 */ @@ -49,6 +59,11 @@ public class Dashboard { */ private Integer totalMeetings; + /** + * 예정된 회의 수 + */ + private Integer scheduledMeetings; + /** * 진행 중인 회의 수 */ @@ -59,11 +74,31 @@ public class Dashboard { */ private Integer completedMeetings; + /** + * 전체 회의록 수 + */ + private Integer totalMinutes; + + /** + * 초안 상태 회의록 수 + */ + private Integer draftMinutes; + + /** + * 확정된 회의록 수 + */ + private Integer finalizedMinutes; + /** * 전체 Todo 수 */ private Integer totalTodos; + /** + * 대기 중인 Todo 수 + */ + private Integer pendingTodos; + /** * 완료된 Todo 수 */ diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java index 8198908..61873da 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Minutes.java @@ -84,4 +84,19 @@ public class Minutes { public boolean isFinalized() { return "FINALIZED".equals(this.status); } + + /** + * 버전 증가 + */ + public void incrementVersion() { + this.version++; + } + + /** + * 회의록 제목 업데이트 + */ + public void updateTitle(String title) { + this.title = title; + this.version++; + } } diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MinutesSection.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MinutesSection.java index 4a332f9..a53c987 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MinutesSection.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/MinutesSection.java @@ -95,4 +95,12 @@ public class MinutesSection { public boolean isVerified() { return Boolean.TRUE.equals(this.verified); } + + /** + * 섹션 정보 업데이트 + */ + public void update(String title, String content) { + this.title = title; + this.content = content; + } } diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Todo.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Todo.java index f6b24c7..e83fcdc 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Todo.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/domain/Todo.java @@ -97,4 +97,15 @@ public class Todo { LocalDate.now().isAfter(this.dueDate) && !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; + } } diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java index 47108db..63ab0f4 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/biz/service/MinutesService.java @@ -76,7 +76,7 @@ public class MinutesService implements } // 제목 수정 - minutes.update(title, minutes.getSections()); + minutes.updateTitle(title); // 저장 Minutes updatedMinutes = minutesWriter.save(minutes); diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/SecurityConfig.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/SecurityConfig.java new file mode 100644 index 0000000..b964113 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/SecurityConfig.java @@ -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; + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/SwaggerConfig.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/SwaggerConfig.java new file mode 100644 index 0000000..b5a190f --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/SwaggerConfig.java @@ -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"); + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/JwtAuthenticationFilter.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b14cade --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/JwtAuthenticationFilter.java @@ -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"); + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/JwtTokenProvider.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..9d4322a --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/JwtTokenProvider.java @@ -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(); + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/UserPrincipal.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/UserPrincipal.java new file mode 100644 index 0000000..d7881c9 --- /dev/null +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/config/jwt/UserPrincipal.java @@ -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; + } +} diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/DashboardGateway.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/DashboardGateway.java index 8e4a998..7ab63bb 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/DashboardGateway.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/DashboardGateway.java @@ -55,19 +55,19 @@ public class DashboardGateway implements DashboardReader { .count(); // 통계 객체 생성 - Dashboard.Statistics statistics = new Dashboard.Statistics( - totalMeetings, - scheduledMeetings, - inProgressMeetings, - completedMeetings, - totalMinutes, - draftMinutes, - finalizedMinutes, - totalTodos, - pendingTodos, - completedTodos, - overdueTodos - ); + Dashboard.Statistics statistics = Dashboard.Statistics.builder() + .totalMeetings((int) totalMeetings) + .scheduledMeetings((int) scheduledMeetings) + .inProgressMeetings((int) inProgressMeetings) + .completedMeetings((int) completedMeetings) + .totalMinutes((int) totalMinutes) + .draftMinutes((int) draftMinutes) + .finalizedMinutes((int) finalizedMinutes) + .totalTodos((int) totalTodos) + .pendingTodos((int) pendingTodos) + .completedTodos((int) completedTodos) + .overdueTodos((int) overdueTodos) + .build(); // 대시보드 생성 return Dashboard.builder() @@ -121,19 +121,19 @@ public class DashboardGateway implements DashboardReader { .count(); // 통계 객체 생성 - Dashboard.Statistics statistics = new Dashboard.Statistics( - totalMeetings, - scheduledMeetings, - inProgressMeetings, - completedMeetings, - totalMinutes, - draftMinutes, - finalizedMinutes, - totalTodos, - pendingTodos, - completedTodos, - overdueTodos - ); + Dashboard.Statistics statistics = Dashboard.Statistics.builder() + .totalMeetings((int) totalMeetings) + .scheduledMeetings((int) scheduledMeetings) + .inProgressMeetings((int) inProgressMeetings) + .completedMeetings((int) completedMeetings) + .totalMinutes((int) totalMinutes) + .draftMinutes((int) draftMinutes) + .finalizedMinutes((int) finalizedMinutes) + .totalTodos((int) totalTodos) + .pendingTodos((int) pendingTodos) + .completedTodos((int) completedTodos) + .overdueTodos((int) overdueTodos) + .build(); // 대시보드 생성 return Dashboard.builder() diff --git a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/TemplateEntity.java b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/TemplateEntity.java index 1100c12..2c634dc 100644 --- a/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/TemplateEntity.java +++ b/meeting/src/main/java/com/unicorn/hgzero/meeting/infra/gateway/entity/TemplateEntity.java @@ -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.meeting.biz.domain.Template; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder;