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