diff --git a/.gitignore b/.gitignore index 72265e9..abdb730 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ build/ out/ *.jar +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ *.war *.ear diff --git a/build.gradle b/build.gradle index b71c79d..fbbe5b2 100644 --- a/build.gradle +++ b/build.gradle @@ -24,11 +24,12 @@ repositories { } ext { - mapstructVersion = "1.6.0.Final" - springDocVersion = "2.5.0" + mapstructVersion = "1.6.3" + springDocVersion = "2.8.9" jjwtVersion = "0.12.6" awsSdkVersion = "2.25.68" springCloudAwsVersion = "3.1.1" + queryDslVersion = "5.1.0" } dependencies { @@ -80,6 +81,15 @@ dependencies { implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:${springCloudAwsVersion}") implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + // queryDsl + implementation "com.querydsl:querydsl-core:${queryDslVersion}" + implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" + annotationProcessor ( + "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta", + // JPA 메타모델 생성용 + "jakarta.persistence:jakarta.persistence-api:3.1.0" + ) + // db runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' @@ -90,11 +100,25 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Reactive 웹 프레임워크 WebFlux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +/** Q 클래스 생성 경로 지정 **/ +def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile + +sourceSets { + main { java.srcDirs += querydslDir } +} + +tasks.withType(JavaCompile).configureEach { + options.annotationProcessorGeneratedSourcesDirectory = querydslDir +} + tasks.named('test') { useJUnitPlatform() } diff --git a/docker-compose.yml b/docker-compose.yml index 9ac43df..437b27f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ volumes: - redis-data:/data # 데이터 지속성을 위한 볼륨 추가 command: redis-server --appendonly yes + restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 30s diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java b/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java index c3c381b..d0cd466 100644 --- a/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java +++ b/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java @@ -2,7 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableJpaAuditing +@EnableScheduling @SpringBootApplication public class GitdeunApplication { diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java b/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java index 904cce0..35d093f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/controller/CodeReferenceController.java @@ -1,5 +1,4 @@ package com.teamEWSN.gitdeun.codereference.controller; public class CodeReferenceController { - -} \ No newline at end of file +} diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java b/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java index e71e410..7eae685 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/entity/CodeReference.java @@ -1,5 +1,31 @@ package com.teamEWSN.gitdeun.codereference.entity; +import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "code_reference") public class CodeReference { - + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "node_id", nullable = false) + private MindmapNode node; + + @Column(name = "file_path", columnDefinition = "TEXT", nullable = false) + private String filePath; + + @Column(name = "start_line", length = 255) + private String startLine; + + @Column(name = "end_line", length = 255) + private String endLine; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java index f36f8e8..ab58460 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReview.java @@ -1,5 +1,46 @@ package com.teamEWSN.gitdeun.codereview.entity; -public class CodeReview { - +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "code_review") +public class CodeReview extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "author_id", nullable = false) + private User author; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "node_id", nullable = false) + private MindmapNode node; + + @Column(name = "ref_id") + private Long refId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @ColumnDefault("'PENDING'") + private CodeReviewStatus status; + + @Column(name = "comment_cnt") + @ColumnDefault("0") + private Integer commentCount; + + @Column(name = "unresolved_thread_cnt") + @ColumnDefault("0") + private Integer unresolvedThreadCount; + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReviewStatus.java b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReviewStatus.java new file mode 100644 index 0000000..99a0912 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/codereview/entity/CodeReviewStatus.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.codereview.entity; + +public enum CodeReviewStatus { + PENDING, + RESOLVED +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java b/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java new file mode 100644 index 0000000..154605b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/controller/CommentController.java @@ -0,0 +1,4 @@ +package com.teamEWSN.gitdeun.comment.controller; + +public class CommentController { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java new file mode 100644 index 0000000..e306b6e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/dto/CommentResponseDto.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.comment.dto; + +public class CommentResponseDto { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/AttachmentType.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/AttachmentType.java new file mode 100644 index 0000000..7433cc5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/AttachmentType.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.comment.entity; + +public enum AttachmentType { + IMAGE, + FILE +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java new file mode 100644 index 0000000..fc833b5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/Comment.java @@ -0,0 +1,44 @@ +package com.teamEWSN.gitdeun.comment.entity; + +import com.teamEWSN.gitdeun.codereview.entity.CodeReview; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "comment") +public class Comment extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "code_review_id", nullable = false) + private CodeReview codeReview; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_comment_id") + private Comment parentComment; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(name = "emoji_type") + private EmojiType emojiType; + + @Column(name = "resolved_at") + private LocalDateTime resolvedAt; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java new file mode 100644 index 0000000..61985d9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/CommentAttachment.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.comment.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "comment_attachment") +public class CommentAttachment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id", nullable = false) + private Comment comment; + + @Column(length = 255, nullable = false) + private String url; + + @Column(name = "file_name", length = 200, nullable = false) + private String fileName; + + @Column(name = "mime_type", length = 100, nullable = false) + private String mimeType; + + @Column(nullable = false) + private Long size; + + @Enumerated(EnumType.STRING) + @Column(name = "attachment_type", nullable = false) + private AttachmentType attachmentType; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java b/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java new file mode 100644 index 0000000..96c76fc --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/entity/EmojiType.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.comment.entity; + +public enum EmojiType { + QUESTION, + IDEA, + BUG, + IMPORTANT, + LOVE +} diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java b/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java new file mode 100644 index 0000000..e9ce416 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/repository/CommentRepository.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.comment.repository; + +public class CommentRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java new file mode 100644 index 0000000..1bbffe9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/comment/service/CommentService.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.comment.service; + +public class CommentService { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java b/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java new file mode 100644 index 0000000..d6cebdb --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java @@ -0,0 +1,41 @@ +package com.teamEWSN.gitdeun.common.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.*; +import org.springframework.stereotype.Component; + +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +public class GlobalLoggingAspect { + + @Pointcut("execution(* com.teamEWSN.gitdeun..*(..))") + private void globalPointcut() { + + } + + @Before("globalPointcut()") + public void logBeforeMethod(JoinPoint joinPoint) { + String methodName = joinPoint.getSignature().toShortString(); + Object[] args = joinPoint.getArgs(); + + log.debug("[실행 메서드]: {} [매개변수]: {}", methodName, Arrays.toString(args)); + } + + @AfterReturning(value = "globalPointcut()", returning = "result") + public void logAfterMethod(JoinPoint joinPoint, Object result) { + String methodName = joinPoint.getSignature().toShortString(); + + log.debug("[종료 메서드]: {} [반환값]: {}", methodName, result); + } + + @AfterThrowing(value = "globalPointcut()", throwing = "ex") + public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) { + String methodName = joinPoint.getSignature().toShortString(); + + log.error("[예외 발생 메서드]: {} [예외]: {}", methodName, ex.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java new file mode 100644 index 0000000..6b19cc8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java @@ -0,0 +1,35 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.teamEWSN.gitdeun.common.util.CacheType; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.Arrays; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + + // 각 캐시 타입에 대한 설정 등록 + Arrays.stream(CacheType.values()) + .forEach(cacheType -> { + cacheManager.registerCustomCache(cacheType.getCacheName(), + Caffeine.newBuilder() + .recordStats() // 캐시 통계 기록 + .expireAfterWrite(Duration.ofHours(cacheType.getExpiredAfterWrite())) // 항목 만료 시간 + .maximumSize(cacheType.getMaximumSize()) // 최대 크기 + .build() + ); + }); + + return cacheManager; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java new file mode 100644 index 0000000..430d07a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/FastApiConfig.java @@ -0,0 +1,21 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class FastApiConfig { + + @Value("${fastapi.base-url}") + private String baseUrl; + + // FastAPI 서버와 통신하기 위한 전용 WebClient Bean + @Bean("fastApiWebClient") + public WebClient webClient() { + return WebClient.builder() + .baseUrl(baseUrl) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java new file mode 100644 index 0000000..15c6b32 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java new file mode 100644 index 0000000..ef3bd56 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + RestTemplate restTemplate() { + return new RestTemplate(); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SchedulingConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SchedulingConfig.java new file mode 100644 index 0000000..9590aee --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java new file mode 100644 index 0000000..ba5ffc3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -0,0 +1,114 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.jwt.*; +import com.teamEWSN.gitdeun.common.jwt.CustomAccessDeniedHandler; +import com.teamEWSN.gitdeun.common.oauth.handler.CustomOAuth2FailureHandler; +import com.teamEWSN.gitdeun.common.oauth.handler.CustomOAuth2SuccessHandler; +import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.annotation.web.configurers.HeadersConfigurer; +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; + + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler; + private final CustomOAuth2FailureHandler customOAuthFailureHandler; + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) // 필요한 경우 세션 요청 + .headers((headerConfig) -> headerConfig + .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)); + + // oauth2 로그인 설정 + http + .oauth2Login((oauth2) -> oauth2 + .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig + .userService(customOAuth2UserService)) +// .defaultSuccessUrl("/oauth/success") // 로그인 성공시 이동할 URL + .successHandler(customOAuth2SuccessHandler) +// .failureUrl("/oauth/fail") // 로그인 실패시 이동할 URL + .failureHandler(customOAuthFailureHandler)) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/oauth/logout") // 로그아웃 성공시 해당 url로 이동 + .clearAuthentication(true) // 현재 요청의 SecurityContext 초기화 + .deleteCookies("refreshToken") // JWT RefreshToken 쿠키를 프론트에서 제거 명시 + ); + + // 경로별 인가 작업 + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") + .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") + .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() + .anyRequest().permitAll() + // .anyRequest().authenticated() + ); + + // 예외 처리 + http + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint(customAuthenticationEntryPoint) // 인증 실패 처리 + .accessDeniedHandler(customAccessDeniedHandler)); // 인가 실패 처리 + + // JwtFilter 추가 + http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + // CORS 설정을 위한 Bean 등록 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = getCorsConfiguration(); + configuration.setAllowedHeaders(java.util.List.of("Authorization", "Content-Type")); + configuration.setExposedHeaders(java.util.List.of("Authorization")); + configuration.setAllowCredentials(true); // 인증 정보 허용 (쿠키 등) + + org.springframework.web.cors.UrlBasedCorsConfigurationSource source = new org.springframework.web.cors.UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 적용 + return source; + } + + + private static CorsConfiguration getCorsConfiguration() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOrigin("http://localhost:5173"); // 개발 환경 + configuration.addAllowedOrigin("https://gitdeun.netlify.app"); + configuration.addAllowedOrigin("https://gitdeun.site"); // 혜택온 도메인 + configuration.addAllowedOrigin("https://www.gitdeun.site"); + configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 + configuration.addAllowedHeader("*"); // 모든 헤더 허용 + return configuration; + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java new file mode 100644 index 0000000..5636642 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -0,0 +1,31 @@ +package com.teamEWSN.gitdeun.common.config; + + +public class SecurityPath { + + // permitAll + public static final String[] PUBLIC_ENDPOINTS = { + "/api/auth/token/refresh", + "/api/auth/oauth/refresh/*", + "/", + + }; + + // hasRole("USER") + public static final String[] USER_ENDPOINTS = { + "/api/auth/connect/github/state", + "/api/users/me", + "/api/users/me/**", + "/api/auth/logout", + "/api/repos", + "/api/repos/**", + "/api/mindmaps/**", + "/api/history/**" + }; + + // hasRole("ADMIN") + public static final String[] ADMIN_ENDPOINTS = { + "/api/admin/**" + }; +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java new file mode 100644 index 0000000..6022d60 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + // OAuth 및 기타 외부 API 통신을 위한 범용 WebClient Bean + @Bean("oauthWebClient") + public WebClient webClient() { + return WebClient.builder().build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java new file mode 100644 index 0000000..1fed0d4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java @@ -0,0 +1,49 @@ +package com.teamEWSN.gitdeun.common.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password}") + private String redisPassword; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(redisHost); + redisConfig.setPort(redisPort); + + if (!redisPassword.isEmpty()) { + redisConfig.setPassword(RedisPassword.of(redisPassword)); + } + + return new LettuceConnectionFactory(redisConfig); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + // Redis에 저장되는 데이터의 직렬화 방식을 지정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return redisTemplate; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java b/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java new file mode 100644 index 0000000..b928d0b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java @@ -0,0 +1,59 @@ +package com.teamEWSN.gitdeun.common.converter; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.util.Base64; + +@Converter +@Component +public class CryptoConverter implements AttributeConverter { + + private static final String ALGORITHM = "AES"; + private static String staticSecretKey; + private Key key; + + @Value("${db.crypto-key}") + public void setStaticSecretKey(String secretKey) { + CryptoConverter.staticSecretKey = secretKey; + } + + @PostConstruct + public void init() { + if (staticSecretKey == null || staticSecretKey.length() < 16) { + throw new IllegalArgumentException("암호화 키는 16자 이상이어야 합니다."); + } + key = new SecretKeySpec(staticSecretKey.substring(0, 16).getBytes(), ALGORITHM); + } + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) return null; + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, key); + return Base64.getEncoder().encodeToString(cipher.doFinal(attribute.getBytes())); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt attribute", e); + } + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) return null; + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, key); + return new String(cipher.doFinal(Base64.getDecoder().decode(dbData))); + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt attribute", e); + } + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java new file mode 100644 index 0000000..53eff89 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.common.cookie; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.cookie") +@Getter +@Setter +public class CookieProperties { + private boolean secure; + private String sameSite; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java new file mode 100644 index 0000000..09490ce --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.cookie; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; +import org.springframework.web.util.WebUtils; + +import java.util.Optional; + +@Component +public class CookieUtil { + + private final CookieProperties cookieProperties; + + public CookieUtil(CookieProperties cookieProperties) { + this.cookieProperties = cookieProperties; + } + // 쿠키 설정 + public void setCookie(HttpServletResponse response, String name, String value, Long maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(cookieProperties.isSecure()) + .path("/") + .sameSite(cookieProperties.getSameSite()) + .maxAge(maxAge) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 쿠키 삭제 + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, null) + .httpOnly(true) + .secure(cookieProperties.isSecure()) + .path("/") + .sameSite(cookieProperties.getSameSite()) + .maxAge(0) // 즉시 만료 + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 특정 쿠키 값 가져오기 + public Optional getCookieValue(HttpServletRequest request, String name) { + Cookie cookie = WebUtils.getCookie(request, name); + return cookie != null ? Optional.of(cookie.getValue()) : Optional.empty(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java new file mode 100644 index 0000000..62cee37 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -0,0 +1,77 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + // 인증 관련 + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-001", "유효하지 않은 액세스 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-002", "리프레시 토큰이 유효하지 않습니다."), + TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH-003", "요청에 토큰이 존재하지 않습니다."), + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "AUTH-004", "접근 권한이 없습니다."), + ACCESS_DENIED(HttpStatus.UNAUTHORIZED, "AUTH-005", "인증되지 않은 유저입니다."), + INVALID_SECRET_KEY(HttpStatus.UNAUTHORIZED, "AUTH-006", "유효하지 않은 비밀 키입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH-007", "유효하지 않은 사용자 정보 또는 비밀번호입니다."), + ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-008", "권한 정보가 없습니다."), + NO_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-009", "토큰이 존재하지 않습니다."), + + // 계정 관련 + USER_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-001", "해당 아이디의 회원을 찾을 수 없습니다."), + USER_NOT_FOUND_BY_EMAIL(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 이메일의 회원을 찾을 수 없습니다."), + ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "ACCOUNT-003", "이미 다른 사용자와 연동된 소셜 계정입니다."), + SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-004", "연동된 소셜 계정 정보를 찾을 수 없습니다."), + USER_SETTING_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-005", "해당 아이디의 설정을 찾을 수 없습니다."), + + // 소셜 로그인 관련 + OAUTH_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-001", "소셜 로그인 처리 중 오류가 발생했습니다."), + UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "OAUTH-002", "지원하지 않는 소셜 로그인 제공자입니다."), + EMAIL_NOT_PROVIDED(HttpStatus.BAD_REQUEST, "OAUTH-003", "소셜 플랫폼에서 이메일 정보를 제공하지 않습니다."), + OAUTH_COMMUNICATION_FAILED(HttpStatus.BAD_GATEWAY, "OAUTH-004", "소셜 플랫폼과의 통신에 실패했습니다."), + SOCIAL_TOKEN_REFRESH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-005", "소셜 플랫폼의 토큰 갱신에 실패했습니다."), + SOCIAL_ACCOUNT_CONNECT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-006", "소셜 계정 연동에 실패했습니다."), + SOCIAL_TOKEN_REFRESH_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "OAUTH-007", "리프레시 토큰 갱신은 지원하지 않습니다. 재인증이 필요합니다."), + + // 리포지토리 관련 + REPO_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "REPO-001", "해당 ID로 요청한 리포지토리를 찾을 수 없습니다."), + REPO_NOT_FOUND_BY_URL(HttpStatus.NOT_FOUND, "REPO-002", "해당 URL로 요청한 리포지토리를 찾을 수 없습니다."), + + // 마인드맵 관련 + MINDMAP_NOT_FOUND(HttpStatus.NOT_FOUND, "MINDMAP-001", "요청한 마인드맵을 찾을 수 없습니다."), + + // 멤버 관련 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER-001", "해당 멤버를 찾을 수 없습니다."), + + // 방문기록 관련 + HISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "VISITHISTORY-001", "방문 기록을 찾을 수 없습니다."), + + // 방문 기록 핀 고정 관련 + USER_NOT_FOUND_FIX_PIN(HttpStatus.NOT_FOUND, "PINNEDHISTORY-001", "핀 고정한 유저를 찾을 수 없습니다."), + PINNEDHISTORY_ALREADY_EXISTS(HttpStatus.CONFLICT, "PINNEDHISTORY-002", "이미 핀 고정한 기록입니다."), + PINNEDHISTORY_NOT_FOUND(HttpStatus.NOT_FOUND, "PINNEDHISTORY-003", "핀 고정 기록을 찾을 수 없습니다."), + + // S3 파일 관련 + // Client Errors (4xx) + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "요청한 파일을 찾을 수 없습니다."), + INVALID_FILE_LIST(HttpStatus.BAD_REQUEST, "FILE-002", "파일 목록이 비어있거나 유효하지 않습니다."), + INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "FILE-003", "파일 경로나 이름이 유효하지 않습니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE-004", "지원하지 않는 파일 형식입니다."), + FILE_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-005", "업로드 가능한 파일 개수를 초과했습니다."), + FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-006", "파일 크기가 허용된 용량을 초과했습니다."), + INVALID_S3_URL(HttpStatus.BAD_REQUEST, "FILE-007", "S3 URL 형식이 올바르지 않습니다."), + + // Server Errors (5xx) + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-501", "파일 업로드 중 서버 오류가 발생했습니다."), + FILE_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-502", "파일 다운로드 중 서버 오류가 발생했습니다."), + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-503", "파일 삭제 중 서버 오류가 발생했습니다."), + FILE_MOVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-504", "파일 이동 중 서버 오류가 발생했습니다."); + + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorResponse.java new file mode 100644 index 0000000..678e896 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorResponse.java @@ -0,0 +1,24 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; +import org.springframework.http.ResponseEntity; + +@ToString +@Getter +@Builder +public class ErrorResponse { + private final String code; + private final String message; + + public static ResponseEntity toResponseEntity(ErrorCode e) { + return ResponseEntity + .status(e.getHttpStatus()) + .body(ErrorResponse.builder() + .code(e.getCode()) + .message(e.getMessage()) + .build()); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalException.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalException.java new file mode 100644 index 0000000..10d2404 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalException.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GlobalException extends RuntimeException { + ErrorCode errorCode; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalExceptionHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..e1ffd1e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.common.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(GlobalException.class) + public ResponseEntity handleGlobalException(GlobalException e) { + return ErrorResponse.toResponseEntity(e.getErrorCode()); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java new file mode 100644 index 0000000..f13039e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class OAuthException extends RuntimeException { + ErrorCode errorCode; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java new file mode 100644 index 0000000..c34e86c --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -0,0 +1,78 @@ +package com.teamEWSN.gitdeun.common.fastapi; + +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.common.fastapi.dto.FastApiCommitTimeResponse; +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.LocalDateTime; + +@Component +public class FastApiClient { + + private final WebClient webClient; // FastApiConfig에서 생성한 Bean을 주입받음 + + public FastApiClient(@Qualifier("fastApiWebClient") WebClient webClient) { + this.webClient = webClient; + } + + /** + * FastAPI 서버에 리포지토리 분석을 요청하고 그 결과를 받아옵니다. + * @param repoUrl 분석할 리포지토리의 URL + * @param prompt 분석에 사용할 프롬프트 + * @param type 분석 타입 (DEV, CHECK) + * @return 분석 결과 DTO + */ + public AnalysisResultDto analyze(String repoUrl, String prompt, MindmapType type) { + // FastAPI 요청 본문을 위한 내부 DTO + AnalysisRequest requestBody = new AnalysisRequest(repoUrl, prompt, type); + + return webClient.post() + .uri("/analyze") // FastAPI에 정의된 분석 엔드포인트 + .body(Mono.just(requestBody), AnalysisRequest.class) + .retrieve() // 응답을 받아옴 + .bodyToMono(AnalysisResultDto.class) // 응답 본문을 DTO로 변환 + .block(); // 비동기 처리를 동기적으로 대기 + } + + + /** + * FastAPI 서버에 특정 GitHub 리포지토리의 최신 커밋 시간을 요청합니다. + * @param githubRepoUrl 조회할 리포지토리의 URL + * @return 최신 커밋 시간 + */ + public LocalDateTime fetchLatestCommitTime(String githubRepoUrl) { + // FastAPI의 가벼운 엔드포인트(예: /check-commit-time)를 호출합니다. + FastApiCommitTimeResponse response = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/check-commit-time") // FastAPI에 정의된 엔드포인트 경로 + .queryParam("url", githubRepoUrl) // 쿼리 파라미터로 URL 전달 + .build()) + .retrieve() // 응답을 받아옴 + .bodyToMono(FastApiCommitTimeResponse.class) // 응답 본문을 DTO로 변환 + .block(); // 비동기 응답을 동기적으로 기다림 + + // null 체크 후 날짜 반환 + if (response == null) { + throw new RuntimeException("FastAPI 서버로부터 최신 커밋 시간 정보를 받아오지 못했습니다."); + } + return response.getLatestCommitAt(); + } + + // TODO: requestAnalysis 등 다른 FastAPI 호출 메서드들도 여기에 구현 + + + + @Getter + @AllArgsConstructor + private static class AnalysisRequest { + private String url; + private String prompt; + private MindmapType type; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java new file mode 100644 index 0000000..eef7d40 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/AnalysisResultDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.common.fastapi.dto; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +public class AnalysisResultDto { + // FastAPI가 반환하는 Repo 관련 정보 + private String defaultBranch; + private String description; + private LocalDateTime githubLastUpdatedAt; + + // FastAPI가 반환하는 Mindmap 관련 정보 + private String mapData; // JSON 형태의 마인드맵 데이터 + private MindmapType type; + private String prompt; + // TODO: FastAPI 응답에 맞춰 필드 정의 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java new file mode 100644 index 0000000..40f202e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/dto/FastApiCommitTimeResponse.java @@ -0,0 +1,12 @@ +package com.teamEWSN.gitdeun.common.fastapi.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter // JSON 역직렬화를 위해 필요 +public class FastApiCommitTimeResponse { + private LocalDateTime latestCommitAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java new file mode 100644 index 0000000..3625c39 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java @@ -0,0 +1,62 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BlacklistService { + + private final RedisTemplate redisTemplate; + private final JwtTokenParser jwtTokenParser; + + private static final String ACCESS_TOKEN_BLACKLIST_PREFIX="blacklist:access:"; + + public void addToBlacklist(String accessToken) { + // Access Token 만료 시간 계산 + Claims claims = jwtTokenParser.parseClaims(accessToken); + Date expiredDate = claims.getExpiration(); + String jti = claims.getId(); + long now = System.currentTimeMillis(); + long timeToLive = (expiredDate.getTime() - now) / 1000; + + if (timeToLive > 0) { + // Redis에 블랙리스트 등록 + String redisKey = ACCESS_TOKEN_BLACKLIST_PREFIX + jti; + redisTemplate.opsForValue().set(redisKey, "blacklisted", timeToLive, TimeUnit.SECONDS); + log.debug("Access Token 블랙리스트 추가 - JTI: {}, 만료 시간: {}초 후", jti, timeToLive); + } + + } + + + public boolean isTokenBlacklisted(String jti) { + String redisKey = ACCESS_TOKEN_BLACKLIST_PREFIX + jti; + try { + Boolean exists = redisTemplate.hasKey(redisKey); + if (Boolean.TRUE.equals(exists)) { + log.warn("블랙리스트에 있는 Access Token으로 접근 시도 중 - JTI: {}", jti); + } + return Boolean.TRUE.equals(exists); + } catch (Exception e) { + log.error("Redis 연결 중 오류 발생 - JTI: {}, 오류: {}", jti, e.getMessage()); + return false; // Redis가 죽었을 때 기본값을 false로 + } + } + + + public void removeFromBlacklist(String jti) { + String redisKey = "blacklist:access:" + jti; + redisTemplate.delete(redisKey); + log.debug("Access Token 블랙리스트에서 삭제 - JTI: {}", jti); + } + + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..47c9be1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.ErrorResponse; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +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.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +// 권한이 부족한 사용자를 접근 금지 역할(로그인 유무와 상관없이 권한이 없는 경우) +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.error("No Authorities", accessDeniedException); + log.error("Request Uri : {}", request.getRequestURI()); + + // ErrorCode 정의 + ErrorCode errorCode = ErrorCode.FORBIDDEN_ACCESS; + + // ErrorResponse 생성 + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // HTTP 응답 설정 + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + // 응답 본문에 JSON 데이터 작성 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..a7873e2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +// 인증되지 않은 사용자의 출입 금지 역할(JWT 없이 접근시) +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + log.error("Not Authenticated Request", authException); + log.error("Request Uri : {}", request.getRequestURI()); + + // UNAUTHORIZED ErrorCode 사용 + ErrorCode errorCode = ErrorCode.ACCESS_DENIED; + + // ErrorResponse 생성 + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // HTTP 응답 설정 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(errorCode.getHttpStatus().value()); + response.setCharacterEncoding("UTF-8"); + + // JSON 응답 반환 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java new file mode 100644 index 0000000..75c3179 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java @@ -0,0 +1,79 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import com.teamEWSN.gitdeun.user.entity.Role; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +/** + * 인증된 사용자의 신분증(사용자의 정보) + * JwtAuthenticationFilter 토큰 정보를 바탕으로 객체 생성 + */ +@Getter +public class CustomUserDetails implements UserDetails, CustomUserPrincipal { + private final Long id; + private final String email; + private final String nickname; + private final String profileImage; + private final Role role; + private final String name; + + public CustomUserDetails(Long id, String email, String nickname, String profileImage, Role role, String name) { + this.id = id; + this.email = email; + this.nickname = nickname; + this.profileImage = profileImage; + this.role = role; + this.name = name; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(role.name())); // 문자열 기반 권한 + } + + @Override + public String getRole() { + return role.name(); + } + + @Override + public Map getAttributes() { + return Collections.emptyMap(); // OAuth2가 아니므로 빈 맵 반환 + } + + @Override + public String getPassword() { + return null; // User 엔티티에 비밀번호가 없으므로 null 반환 + } + + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java new file mode 100644 index 0000000..241a4fd --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import java.util.Map; + +public interface CustomUserPrincipal { + Long getId(); + String getEmail(); + String getNickname(); + String getRole(); + String getName(); + String getProfileImage(); + Map getAttributes(); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3c5ef4f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,50 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + @Value("${jwt.secret-key}") + private String secretKey; + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private static final String BEARER = "Bearer "; + + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + // access token이 있고, BEARER로 시작한다면 + if (authHeader != null && authHeader.startsWith(BEARER)) { + String token = authHeader.substring(BEARER.length()); + // 토큰 검증 + if (jwtTokenProvider.validateToken(token)) { + // 유효한 토큰: 유저 정보 가져옴 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java new file mode 100644 index 0000000..0ca4da4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java @@ -0,0 +1,23 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class JwtToken { + private String grantType; + private String accessToken; + private String refreshToken; + + // 정적 메서드 + public static JwtToken of(String accessToken, String refreshToken) { + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java new file mode 100644 index 0000000..af0bd43 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java @@ -0,0 +1,45 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; + +@Slf4j +@Component +public class JwtTokenParser { + + private final SecretKey secretKey; + + public JwtTokenParser(@Value("${jwt.secret-key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + // Access Token에서 Claims 추출 + public Claims parseClaims(String accessToken) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + // 토큰에서 아이디 정보 추출 + public String getRealIdFromToken(String accessToken) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + return claims.getSubject(); + }} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..b052f47 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java @@ -0,0 +1,219 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.teamEWSN.gitdeun.common.oauth.dto.CustomOAuth2User; +import com.teamEWSN.gitdeun.user.entity.Role; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.service.UserService; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.UUID; + +@Slf4j +@Getter +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + + @Autowired + private UserService userService; + + @Autowired + private RefreshTokenService refreshTokenService; + + @Autowired + private BlacklistService blackListService; + + @Autowired + private JwtTokenParser jwtTokenParser; + + @Value("${jwt.access-expired}") + private Long accessTokenExpired; + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + + public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + public JwtToken generateToken(Authentication authentication) { + Object principal = authentication.getPrincipal(); + + Long userId; + Role role; + + switch (principal) { + // 소셜 로그인 성공 후 CustomOAuth2User를 처리 + case CustomOAuth2User customUser -> { + userId = customUser.getUserId(); + role = customUser.getRole(); + } + // 기존 JWT로 인증된 사용자를 처리 + case CustomUserDetails userDetails -> { + userId = userDetails.getId(); + role = Role.valueOf(userDetails.getRole()); + } + case OidcUser oidc -> { + userId = userService.upsertAndGetId( + oidc.getEmail(), oidc.getFullName(), oidc.getPicture(), oidc.getFullName()); + role = Role.USER; + } + case OAuth2User oauth2 -> { + String email = (String) oauth2.getAttributes().get("email"); + userId = userService.upsertAndGetId( + email, (String) oauth2.getAttributes().get("name"), + (String) oauth2.getAttributes().get("avatar_url"), (String) oauth2.getAttributes().get("login")); + role = Role.USER; + } + case null, default -> throw new IllegalStateException("Unsupported principal"); + } + + long now = System.currentTimeMillis(); + Date exp = new Date(now + accessTokenExpired * 1000); + + String jti = UUID.randomUUID().toString(); + + String accessToken = Jwts.builder() + .subject(String.valueOf(userId)) + .issuedAt(new Date(now)) + .id(jti) + .claim("role", role.name()) + .expiration(exp) + .signWith(secretKey) + .compact(); + + String refreshToken = UUID.randomUUID().toString(); + refreshTokenService.saveRefreshToken(refreshToken, userId, refreshTokenExpired); + + return JwtToken.of(accessToken, refreshToken); + } + + // DB 조회 후 UserDetails 생성 + public Authentication getAuthentication(String token) { + Claims claims = jwtTokenParser.parseClaims(token); + Long userId = Long.valueOf(claims.getSubject()); + String roleName = claims.get("role", String.class); + Role role = Role.valueOf(roleName); + User user = userService.findById(userId); + + CustomUserDetails userDetails = new CustomUserDetails(user.getId(), user.getEmail(), + user.getNickname(), user.getProfileImage(), role, user.getName()); + + Collection authorities = Collections.singletonList(role); + + return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + } + +// // 토큰 생성 - 유저 정보 이용 +// public JwtToken generateToken(Authentication authentication) { +// +// long now = (new Date()).getTime(); +// Date accessTokenExpiration = new Date(now + accessTokenExpired * 1000); +// +// CustomUserPrincipal userPrincipal = (CustomUserPrincipal) authentication.getPrincipal(); +// +// Long userId = ((CustomUserDetails) userPrincipal).getId(); +// +// String jti = UUID.randomUUID().toString(); +// // Access Token 생성 +// String accessToken = Jwts.builder() +// .subject(String.valueOf(userId)) // Subject를 불변값인 userId로 설정 +// .issuedAt(new Date()) // 발행 시간 +// .id(jti) // blacklist 관리를 위한 jwt token id +// .claim("email", userPrincipal.getEmail()) // 이메일 +// .claim("nickname", userPrincipal.getNickname()) // 닉네임 +// .claim("role", userPrincipal.getRole()) // 사용자 역할(Role) +// .claim("name",userPrincipal.getName()) +// .claim("profileImage", userPrincipal.getProfileImage()) // 프로필 이미지 추가 +// .expiration(accessTokenExpiration) // 만료 시간 +// .signWith(secretKey) // 서명 +// .compact(); +// +// // Refresh Token 생성 (임의의 값 생성) +// String refreshToken = UUID.randomUUID().toString(); +// +// // Redis에 Refresh Token 정보 저장 +// refreshTokenService.saveRefreshToken( refreshToken, userPrincipal.getEmail(), refreshTokenExpired); +// +// +// // JWT Token 객체 반환 +// return JwtToken.builder() +// .grantType("Bearer") +// .accessToken(accessToken) +// .refreshToken(refreshToken) +// .build(); +// +// } +// +// +// // 토큰에서 유저 정보 추출 +// public Authentication getAuthentication(String accessToken) { +// // 토큰에서 Claims 추출 +// Claims claims = jwtTokenParser.parseClaims(accessToken); +// +// // 권한 정보 확인 +// if (claims.get("role") == null) { +// throw new GlobalException(ErrorCode.ROLE_NOT_FOUND); +// } +// +// // 클레임에서 모든 사용자 정보 추출 +// Long id = Long.parseLong(claims.getSubject()); +// String email = claims.get("email", String.class); +// String nickname = claims.get("nickname", String.class); +// String name = claims.get("name", String.class); +// String profileImage = claims.get("profileImage", String.class); +// Role role = Role.valueOf(claims.get("role", String.class)); +// +// CustomUserDetails userDetails = new CustomUserDetails(id, email, nickname, profileImage, role, name); +// +// Collection authorities = +// Collections.singletonList(role::name); +// +// // Authentication 객체 반환 +// return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); +// +// } + + // 토큰 정보 검증 + public boolean validateToken(String token) { + log.debug("validateToken start"); + try { + Jws claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + + String jti = claims.getPayload().getId(); // JTI 추출 + return !blackListService.isTokenBlacklisted(jti); + + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.error("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.error("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.error("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.error("JWT claims string is empty.", e); + } + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java new file mode 100644 index 0000000..e43edea --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java @@ -0,0 +1,29 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@AllArgsConstructor +@Builder +@NoArgsConstructor +@RedisHash("refreshToken") +public class RefreshToken { + + @Id + private String refreshToken; + + @Indexed + private Long userId; + private Long issuedAt; + + // Time to live (TTL) 설정, Redis에 만료 시간을 설정 + @TimeToLive + private Long ttl; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java new file mode 100644 index 0000000..82b125f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java new file mode 100644 index 0000000..f8b020f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.common.jwt; + + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + + public void saveRefreshToken(String refreshToken, Long userId, long refreshTokenExpired) { + RefreshToken token = RefreshToken.builder() + .refreshToken(refreshToken) + .userId(userId) + .issuedAt(System.currentTimeMillis()) + .ttl(refreshTokenExpired) // @TimeToLive에 사용될 만료 시간 + .build(); + + refreshTokenRepository.save(token); + } + + + public Optional getRefreshToken(String refreshToken) { + return refreshTokenRepository.findById(refreshToken); + } + + + public void deleteRefreshToken(String refreshToken) { + refreshTokenRepository.deleteById(refreshToken); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java new file mode 100644 index 0000000..ea70964 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/CustomOAuth2User.java @@ -0,0 +1,41 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + +import com.teamEWSN.gitdeun.user.entity.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class CustomOAuth2User implements OAuth2User { + + private final Long userId; + private final Role role; + + public CustomOAuth2User(Long userId, Role role) { + this.userId = userId; + this.role = role; + } + + @Override + public Map getAttributes() { + // 인증 성공 후 시스템 내부에서만 사용, 소셜 플랫폼의 attributes 필요 x + return Collections.emptyMap(); + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(this.role.name())); + } + + @Override + public String getName() { + // Spring Security에서 Principal의 이름을 식별하기 위해 우리 서비스의 고유 ID로 사용 + return String.valueOf(this.userId); + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java new file mode 100644 index 0000000..05176c5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GitHubEmailDto.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class GitHubEmailDto { + + private String email; + private boolean primary; + private boolean verified; + private String visibility; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GoogleTokenResponse.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GoogleTokenResponse.java new file mode 100644 index 0000000..67d7b5e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/GoogleTokenResponse.java @@ -0,0 +1,12 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleTokenResponse( + @JsonProperty("access_token") String accessToken, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("expires_in") Long expiresIn, + @JsonProperty("scope") String scope, + @JsonProperty("id_token") String idToken +) {} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/SocialConnectionResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/SocialConnectionResponseDto.java new file mode 100644 index 0000000..b2c7ef2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/SocialConnectionResponseDto.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.List; + +@Getter +@AllArgsConstructor +public class SocialConnectionResponseDto { + private List connectedProviders; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java new file mode 100644 index 0000000..eb7dcff --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +import java.util.Map; + +public class GitHubResponseDto implements OAuth2ResponseDto { + + private final Map attributes; + + public GitHubResponseDto(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProvider() { + return "github"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + return attributes.get("email") != null ? attributes.get("email").toString() : null; + } + + @Override + public String getName() { + return attributes.get("name") != null ? attributes.get("name").toString() : attributes.get("login").toString(); + } + + @Override + public String getNickname() { + return attributes.get("login").toString(); + } + + @Override + public String getProfileImageUrl() { + return attributes.get("avatar_url").toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java new file mode 100644 index 0000000..01da254 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +import java.util.Map; + +public class GoogleResponseDto implements OAuth2ResponseDto { + private final Map attributes; + + public GoogleResponseDto(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getProviderId() { + return attributes.get("sub").toString(); + } + + @Override + public String getEmail() { + return attributes.get("email").toString(); + } + + @Override + public String getName() { + return attributes.get("name").toString(); + } + + @Override + public String getNickname() { + // 별도 닉네임이 없으므로 이름(name)을 그대로 반환 + return attributes.get("name").toString(); + } + + @Override + public String getProfileImageUrl() { + return attributes.get("picture").toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java new file mode 100644 index 0000000..4204c25 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +// 제공자마다 반환 형태가 달라서 interface 생성. 제공자별 구현체 필요 +public interface OAuth2ResponseDto { + + // 제공자 (Ex. google, github) + String getProvider(); + + // 제공자가 발급해주는 고유 ID + String getProviderId(); + + String getEmail(); + + String getName(); + + String getNickname(); + + String getProfileImageUrl(); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java new file mode 100644 index 0000000..52054d9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.common.oauth.entity; + +public enum OauthProvider { + GOOGLE, + GITHUB +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java new file mode 100644 index 0000000..ccbb3da --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.oauth.entity; + +import com.teamEWSN.gitdeun.common.converter.CryptoConverter; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "social_connection") +public class SocialConnection extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false) + private OauthProvider provider; + + @Column(name = "provider_id", nullable = false) + private String providerId; + + @Convert(converter = CryptoConverter.class) + @Column(name = "access_token", length = 1024, nullable = false) + private String accessToken; + + @Convert(converter = CryptoConverter.class) + @Column(name = "refresh_token", length = 1024) + private String refreshToken; + + + @Builder + public SocialConnection(User user, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + this.user = user; + this.provider = provider; + this.providerId = providerId; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public void updateTokens(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken != null ? refreshToken : this.refreshToken; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java new file mode 100644 index 0000000..1d66c20 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.common.oauth.handler; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.OAuthException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomOAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) { + + // 예외 유형에 따른 적절한 ErrorCode 설정 + ErrorCode errorCode; + + if (exception instanceof BadCredentialsException) { + // 잘못된 자격 증명 (비밀번호 오류) + errorCode = ErrorCode.INVALID_CREDENTIALS; + + } else if (exception instanceof InsufficientAuthenticationException) { + // 인증에 필요한 비밀 키가 유효하지 않음 + errorCode = ErrorCode.INVALID_SECRET_KEY; + + } else { + // 기본적인 Access Denied 처리 + errorCode = ErrorCode.ACCESS_DENIED; + } + + throw new OAuthException(errorCode); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java new file mode 100644 index 0000000..912d06b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java @@ -0,0 +1,82 @@ +package com.teamEWSN.gitdeun.common.oauth.handler; + +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.common.jwt.JwtTokenProvider; +import com.teamEWSN.gitdeun.common.oauth.service.OAuthStateService; +import com.teamEWSN.gitdeun.user.service.AuthService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final OAuthStateService oAuthStateService; + private final AuthService authService; + private final CookieUtil cookieUtil; + + @Value("${app.front-url}") + private String frontUrl; + + @Override + public void onAuthenticationSuccess(HttpServletRequest req, + HttpServletResponse res, + Authentication auth) throws IOException { + + String state = req.getParameter("state"); + String purpose = state != null ? oAuthStateService.consumeState(state) : null; + + // 1) 계정 연동 시나리오 + if (purpose != null && purpose.startsWith("connect:")) { + handleAccountConnection(purpose, (OAuth2User) auth.getPrincipal(), res); + return; + } + + // 2) 일반 로그인 + handleStandardLogin(req, res, auth); + } + + /** + * 일반 로그인 성공 시 JWT 토큰을 발급 및 클라이언트로 리디렉션 + */ + private void handleStandardLogin(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + // JWT 액세스 토큰과 리프레시 토큰을 생성합니다. + JwtToken jwtToken = jwtTokenProvider.generateToken(authentication); + + // 리프레시 토큰은 보안을 위해 HttpOnly 쿠키에 저장합니다. + cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpired()); + + // 액세스 토큰은 URL 프래그먼트로 프론트엔드에 전달합니다. + String targetUrl = UriComponentsBuilder.fromUriString(frontUrl + "/oauth/callback") + .fragment("accessToken=" + jwtToken.getAccessToken()) + .build() + .toUriString(); + + clearAuthenticationAttributes(request); // 세션 클린업 + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } + + /** + * 기존 계정에 새로운 소셜 계정을 연동하는 흐름을 처리 + */ + private void handleAccountConnection(String purpose, OAuth2User oAuth2User, HttpServletResponse response) throws IOException { + Long userId = Long.parseLong(purpose.split(":")[1]); + authService.connectGithubAccount(oAuth2User, userId); // 계정 연동 로직 호출 + + String targetUrl = frontUrl + "/oauth/callback#connected=true"; + response.sendRedirect(targetUrl); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java new file mode 100644 index 0000000..1546c75 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java @@ -0,0 +1,22 @@ +package com.teamEWSN.gitdeun.common.oauth.repository; + +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SocialConnectionRepository extends JpaRepository { + + /** + * 소셜 플랫폼과 해당 플랫폼에서의 고유 ID로 소셜 연동 정보를 조회 + * @param provider 소셜 플랫폼 (GOOGLE, GITHUB 등) + * @param providerId 해당 소셜 플랫폼에서의 사용자 고유 ID + */ + Optional findByProviderAndProviderId(OauthProvider provider, String providerId); + + // 소셜 로그인한 사용자의 소셜 연동 정보 조회 + Optional findByUserIdAndProvider(Long userId, OauthProvider provider); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..d934856 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -0,0 +1,156 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.oauth.dto.GitHubEmailDto; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GoogleResponseDto; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.OAuth2ResponseDto; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.user.entity.Role; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.common.oauth.dto.CustomOAuth2User; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final GitHubApiHelper gitHubApiHelper; + private final SocialTokenRefreshService socialTokenRefreshService; + private final UserRepository userRepository; + private final SocialConnectionRepository socialConnectionRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User; + try { + oAuth2User = super.loadUser(userRequest); + } catch (Exception e) { + // 외부 소셜 플랫폼과의 통신 자체에서 에러가 발생한 경우 + log.error("OAuth2 사용자 정보를 불러오는 데 실패했습니다.", e); + throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); + } + + User user = processUser(oAuth2User, userRequest); + return new CustomOAuth2User(user.getId(), user.getRole()); + } + + // @Transactional + public User processUser(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + OAuth2ResponseDto dto = getOAuth2ResponseDto(oAuth2User, userRequest); + OauthProvider provider = OauthProvider.valueOf(dto.getProvider().toUpperCase()); + String providerId = dto.getProviderId(); + String accessToken = userRequest.getAccessToken().getTokenValue(); + String refreshToken = (String) userRequest.getAdditionalParameters().get("refresh_token"); + + /* ② 이미 연결된 계정 → 토큰 갱신 로직 추상화 */ + return socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + .map(conn -> { + // provider 별 refresh 정책 + socialTokenRefreshService.refreshSocialToken(conn, accessToken, refreshToken); + return conn.getUser(); + }) + .orElseGet(() -> createOrConnect(dto, provider, providerId, accessToken, refreshToken)); + } + + // OAuth2 공급자로부터 받은 사용자 정보를 기반으로 OAuth2ResponseDto를 생성(인스턴스 메서드) + private OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + Map attr = new HashMap<>(oAuth2User.getAttributes()); + + if (registrationId.equalsIgnoreCase("google")) { + return new GoogleResponseDto(attr); + } + if (registrationId.equalsIgnoreCase("github")) { + /* ① 기본 프로필에 e-mail 없으면 /user/emails 호출 */ + if (attr.get("email") == null) { + // accessToken 으로 GitHub 보조 API 호출 + List emails = + gitHubApiHelper.getPrimaryEmails(userRequest.getAccessToken().getTokenValue()); + attr.put("email", + emails.stream().filter(GitHubEmailDto::isPrimary) + .findFirst().map(GitHubEmailDto::getEmail).orElse(null)); + } + return new GitHubResponseDto(attr); + } + // 지원하지 않는 소셜 로그인 제공자 + throw new GlobalException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER); + } + + /** + * 사용자가 존재하면 계정을 연결하고, 존재하지 않으면 새로 생성합니다. + */ + private User createOrConnect(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + // 이메일로 기존 사용자를 찾습니다. + return userRepository.findByEmailAndDeletedAtIsNull(response.getEmail()) + .map(user -> { + // 사용자가 존재하면, 새 소셜 계정을 연결 + connectSocialAccount(user, provider, providerId, accessToken, refreshToken); + return user; + }) + .orElseGet(() -> { + // 사용자가 존재하지 않으면, 새 사용자를 생성 + return createNewUser(response, provider, providerId, accessToken, refreshToken); + }); + } + + private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + // provider별 다른 Nickname 처리 로직 + String nickname = response.getNickname(); + if (provider == OauthProvider.GOOGLE) { + nickname = nickname + "_" + UUID.randomUUID().toString().substring(0, 6); + } + + User newUser = User.builder() + .email(response.getEmail()) + .name(response.getName()) // GitHub의 경우 full name, Google의 경우 name + .nickname(nickname) + .profileImage(response.getProfileImageUrl()) + .role(Role.USER) + .build(); + User savedUser = userRepository.save(newUser); + + connectSocialAccount(savedUser, provider, providerId, accessToken, refreshToken); + return savedUser; + } + + private void connectSocialAccount(User user, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + .ifPresent(connection -> { + // 이 소셜 계정이 이미 다른 유저와 연결되어 있다면 예외 발생 + if (!connection.getUser().getId().equals(user.getId())) { + throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); + } + }); + + SocialConnection connection = SocialConnection.builder() + .user(user) + .provider(provider) + .providerId(providerId) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + socialConnectionRepository.save(connection); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java new file mode 100644 index 0000000..12a779d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -0,0 +1,83 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.oauth.dto.GitHubEmailDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +public class GitHubApiHelper { + + private final WebClient webClient; + + public GitHubApiHelper(@Qualifier("oauthWebClient") WebClient webClient) { + this.webClient = webClient; + } + + @Value("${spring.security.oauth2.client.registration.github.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.github.client-secret}") + private String clientSecret; + + + /** + * Access Token으로 사용자의 이메일 목록을 조회합니다. + * GitHub 기본 사용자 정보에 이메일이 포함되지 않은 경우 사용됩니다. + * @param accessToken GitHub Access Token + * @return 이메일 정보 DTO 리스트 + */ + public List getPrimaryEmails(String accessToken) { + String emailsUri = "https://api.github.com/user/emails"; + + List emails = webClient.get() + .uri(emailsUri) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + + if (emails == null || emails.isEmpty()) { + log.error("GitHub 이메일 정보를 가져올 수 없습니다."); + throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); + } + return emails; + } + + + /** + * GitHub OAuth 토큰을 해지합니다. + * @param accessToken 해지할 Access Token + * @return Mono + */ + public Mono revokeToken(String accessToken) { + String revokeUrl = "https://api.github.com/applications/" + clientId + "/token"; + + String credentials = Base64.getEncoder().encodeToString( + (clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8) + ); + + return webClient.post() + .uri(revokeUrl) + .header(HttpHeaders.AUTHORIZATION, "Basic " + credentials) + .header(HttpHeaders.ACCEPT, "application/vnd.github.v3+json") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("access_token", accessToken)) + .retrieve() + .bodyToMono(Void.class); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java new file mode 100644 index 0000000..753a0cd --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -0,0 +1,107 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.oauth.dto.GoogleTokenResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import reactor.core.publisher.Mono; + + +@Slf4j +@Component +public class GoogleApiHelper { + + private final WebClient webClient; + + public GoogleApiHelper(@Qualifier("oauthWebClient") WebClient webClient) { + this.webClient = webClient; + } + + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String googleClientId; + + @Value("${spring.security.oauth2.client.registration.google.client-secret}") + private String googleClientSecret; + + + /** + * 토큰이 만료되었는지 확인 + * @param accessToken 확인할 액세스 토큰 + * @return 만료 여부 (true: 만료됨, false: 유효함) + */ + protected boolean isExpired(String accessToken) { + // String validateUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo"; + try { + // 토큰 정보 요청 + webClient.get() + .uri(uriBuilder -> uriBuilder + .scheme("https") + .host("www.googleapis.com") + .path("/oauth2/v1/tokeninfo") + .queryParam("access_token", accessToken) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + + // 응답이 있으면 토큰이 유효함 + return false; // 만료되지 않음 + } catch (WebClientResponseException e) { + // 401, 400 등의 에러는 토큰이 만료되었거나 유효하지 않음 + log.debug("토큰 검증 오류: {}", e.getMessage()); + return true; // 만료됨 + } catch (Exception e) { + // 기타 예외 + log.error("토큰 검증 중 예상치 못한 오류: {}", e.getMessage()); + return true; // 안전하게 만료로 취급 + } + } + + /** + * 리프레시 토큰으로 새 액세스 토큰 요청 + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 응답 객체 + */ + protected GoogleTokenResponse refreshToken(String refreshToken) { + String tokenUrl = "https://oauth2.googleapis.com/token"; + + // 요청 바디 구성 + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", googleClientId); + formData.add("client_secret", googleClientSecret); + formData.add("refresh_token", refreshToken); + formData.add("grant_type", "refresh_token"); + + try { + // 토큰 갱신 요청 + return webClient.post() + .uri(tokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(formData)) + .retrieve() + .bodyToMono(GoogleTokenResponse.class) + .block(); + } catch (Exception e) { + log.error("Google 토큰 갱신 실패: {}", e.getMessage()); + throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_FAILED); + } + } + + + public Mono revokeToken(String accessToken) { + String revokeUrl = "https://accounts.google.com/o/oauth2/revoke"; + return webClient.post() + .uri(uriBuilder -> uriBuilder.path(revokeUrl).queryParam("token", accessToken).build()) + .retrieve() + .bodyToMono(Void.class); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java new file mode 100644 index 0000000..cf776bb --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/OAuthStateService.java @@ -0,0 +1,29 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class OAuthStateService { + + private final RedisTemplate redisTemplate; + private static final Duration EXPIRATION = Duration.ofMinutes(3); + + public String createState(String purpose) { + String state = UUID.randomUUID().toString(); + redisTemplate.opsForValue().set("oauth:state:" + state, purpose, EXPIRATION); + return state; + } + + public String consumeState(String state) { + String key = "oauth:state:" + state; + String purpose = redisTemplate.opsForValue().get(key); + redisTemplate.delete(key); + return purpose; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java new file mode 100644 index 0000000..3ee34bc --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java @@ -0,0 +1,84 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.dto.GoogleTokenResponse; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.teamEWSN.gitdeun.common.exception.ErrorCode.*; + + +// 레포 및 마인드맵 호출 시 소셜로그인 토큰 갱신 호출 +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class SocialTokenRefreshService { + + private final SocialConnectionRepository socialConnectionRepository; + private final GitHubApiHelper gitHubApiHelper; + private final GoogleApiHelper googleApiHelper; + + + // 기존 refreshToken 기반 갱신(주기적/자동 갱신) + public void refreshSocialToken(Long userId, OauthProvider provider) { + SocialConnection connection = socialConnectionRepository.findByUserIdAndProvider(userId, provider) + .orElseThrow(() -> new GlobalException(SOCIAL_CONNECTION_NOT_FOUND)); + + switch (provider) { + case GOOGLE -> refreshGoogle(connection, Optional.empty(), Optional.empty()); + case GITHUB -> { + log.warn("GitHub는 토큰 갱신을 지원하지 않습니다. 재인증이 필요합니다."); + throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_NOT_SUPPORTED); + } + } + + // 갱신 후 저장 명시 + socialConnectionRepository.save(connection); + } + + // oauth 새로운 토큰 제공 시 갱신(로그인 콜백) + public void refreshSocialToken(SocialConnection conn, + String latestAccess, String latestRefresh) { + switch (conn.getProvider()) { + case GOOGLE -> refreshGoogle(conn, Optional.ofNullable(latestAccess), + Optional.ofNullable(latestRefresh)); + // GitHub은 refresh 불가 + case GITHUB -> conn.updateTokens(latestAccess, null); // accessToken만 교체 + } + + socialConnectionRepository.save(conn); + } + + + private void refreshGoogle(SocialConnection conn, + Optional latestAccessOpt, + Optional latestRefreshOpt) { + // 1. latestAccess가 주어지고 유효하면 교체 + if (latestAccessOpt.isPresent() && !googleApiHelper.isExpired(latestAccessOpt.get())) { + String newRefresh = latestRefreshOpt.orElse(conn.getRefreshToken()); + conn.updateTokens(latestAccessOpt.get(), newRefresh); + return; + } + + // 2. refreshToken 기반 재발급 (latestRefresh가 있으면 그것 사용, 없으면 기존) + String refreshToUse = latestRefreshOpt.orElse(conn.getRefreshToken()); + if (refreshToUse == null) { + throw new GlobalException(INVALID_REFRESH_TOKEN); + } + + GoogleTokenResponse res = googleApiHelper.refreshToken(refreshToUse); + + String newRefresh = (res.refreshToken() != null) ? res.refreshToken() : conn.getRefreshToken(); + conn.updateTokens(res.accessToken(), newRefresh); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java new file mode 100644 index 0000000..de56ba0 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java @@ -0,0 +1,56 @@ +package com.teamEWSN.gitdeun.common.s3.controller; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.s3.service.S3BucketService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/s3/bucket") +@RequiredArgsConstructor +public class S3BucketController { + + private final S3BucketService s3BucketService; + private static final int MAX_FILE_COUNT = 10; + + @PostMapping("/upload") + public ResponseEntity> uploadFiles( + @RequestParam("files") List files, + @RequestParam("path") String path + ) { + // FILE-005: 업로드 가능한 파일 개수를 초과했습니다. + if (files.size() > MAX_FILE_COUNT) { + throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); + } + + List fileUrls = s3BucketService.upload(files, path); + return ResponseEntity.ok(fileUrls); + } + + @DeleteMapping("/delete") + public ResponseEntity deleteFiles(@RequestBody List urls) { + s3BucketService.remove(urls); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/download") + public ResponseEntity downloadFile(@RequestParam("url") String url) { + Resource resource = s3BucketService.download(url); + String filename = URLEncoder.encode(resource.getFilename(), StandardCharsets.UTF_8); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(resource); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java new file mode 100644 index 0000000..6ed3ec5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java @@ -0,0 +1,127 @@ +package com.teamEWSN.gitdeun.common.s3.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import io.awspring.cloud.s3.S3Resource; +import io.awspring.cloud.s3.S3Template; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class S3BucketService { + + private final S3Template s3Template; + + @Value("${cloud.aws.s3.bucket.name}") + private String bucketName; + + public List upload(List files, String path) { + // FILE-002: 파일 목록이 비어있거나 유효하지 않습니다. + if (files == null || files.stream().allMatch(MultipartFile::isEmpty)) { + throw new GlobalException(ErrorCode.INVALID_FILE_LIST); + } + + List uploadedUrls = new ArrayList<>(); + for (MultipartFile file : files) { + if (file.isEmpty()) continue; + + if (!isValidFileType(file.getOriginalFilename())) { + // FILE-004: 지원하지 않는 파일 형식입니다. + throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); + } + + String fullPath = generateValidPath(path) + createUniqueFileName(file.getOriginalFilename()); + + try { + S3Resource s3Resource = s3Template.upload(bucketName, fullPath, file.getInputStream()); + uploadedUrls.add(s3Resource.getURL().toString()); + } catch (IOException | SdkException e) { + // FILE-501: 파일 업로드 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + return uploadedUrls; + } + + public void remove(List urls) { + // FILE-002: 파일 목록이 비어있거나 유효하지 않습니다. + if (urls == null || urls.isEmpty()) { + throw new GlobalException(ErrorCode.INVALID_FILE_LIST); + } + + for (String url : urls) { + String key = extractKeyFromUrl(url); + + try { + if (!s3Template.objectExists(bucketName, key)) { + // FILE-001: 요청한 파일을 찾을 수 없습니다. + throw new GlobalException(ErrorCode.FILE_NOT_FOUND); + } + s3Template.deleteObject(bucketName, key); + } catch (SdkException e) { + // FILE-503: 파일 삭제 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_DELETE_FAILED); + } + } + } + + public S3Resource download(String url) { + String key = extractKeyFromUrl(url); + + try { + if (!s3Template.objectExists(bucketName, key)) { + // FILE-001: 요청한 파일을 찾을 수 없습니다. + throw new GlobalException(ErrorCode.FILE_NOT_FOUND); + } + return s3Template.download(bucketName, key); + } catch (SdkException e) { + // FILE-502: 파일 다운로드 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_DOWNLOAD_FAILED); + } + } + + private String extractKeyFromUrl(String url) { + try { + String urlPrefix = "https://" + bucketName + ".s3."; + int startIndex = url.indexOf(urlPrefix); + int keyStartIndex = url.indexOf('/', startIndex + urlPrefix.length()); + return url.substring(keyStartIndex + 1); + } catch (Exception e) { + // FILE-007: S3 URL 형식이 올바르지 않습니다. + throw new GlobalException(ErrorCode.INVALID_S3_URL); + } + } + + private String generateValidPath(String path) { + if (path == null || path.trim().isEmpty()) { + return ""; + } + if (path.contains("..")) { + // FILE-003: 파일 경로나 이름이 유효하지 않습니다. + throw new GlobalException(ErrorCode.INVALID_FILE_PATH); + } + return path.replaceAll("^/+|/+$", "") + "/"; + } + + private String createUniqueFileName(String originalFileName) { + String extension = StringUtils.getFilenameExtension(originalFileName); + return UUID.randomUUID() + "." + extension; + } + + private boolean isValidFileType(String filename) { + if (filename == null) return false; + String extension = StringUtils.getFilenameExtension(filename.toLowerCase()); + List allowedExtensions = List.of("jpg", "jpeg", "png", "gif", "pdf", "docs"); // 허용 확장자 + return allowedExtensions.contains(extension); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java b/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java new file mode 100644 index 0000000..3b04b77 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/AuditedEntity.java @@ -0,0 +1,24 @@ +package com.teamEWSN.gitdeun.common.util; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@MappedSuperclass +public class AuditedEntity extends CreatedEntity { + + @LastModifiedDate + @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime updatedAt; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java b/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java new file mode 100644 index 0000000..02dd611 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java @@ -0,0 +1,26 @@ +package com.teamEWSN.gitdeun.common.util; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + + +/** + * 현재 인증된 사용자의 정보를 가져옴 + * 컨트롤러가 아닌 다른 서비스 계층 등에서 사용자 ID가 필요할 때 편리하게 사용 + */ +@Component +public class AuthenticateUser { + public Long authenticateUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() || authentication.getPrincipal().equals("anonymousUser")) { + return 0L; // 인증되지 않은 사용자일 경우 0 반환 + } + + // 인증된 사용자의 경우 userId 반환 + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + return userDetails.getId(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java new file mode 100644 index 0000000..3530db8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.util; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheType { + SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200); // 자동완성 캐시 + + private final String cacheName; + private final int expiredAfterWrite; // 시간(hour) 단위 + private final int maximumSize; // 최대 캐시 항목 수 +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java new file mode 100644 index 0000000..dc5a3f7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CreatedEntity.java @@ -0,0 +1,23 @@ +package com.teamEWSN.gitdeun.common.util; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@MappedSuperclass +public class CreatedEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java index 720d849..40cceac 100644 --- a/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/Invitation.java @@ -1,5 +1,47 @@ package com.teamEWSN.gitdeun.invitation.entity; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "invitation") public class Invitation { - + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + @Column(length = 36, nullable = false, unique = true) + private String token; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + @ColumnDefault("'READ_ONLY'") + private InvitationStatus status; + + @CreationTimestamp + @Column(name = "invited_at", updatable = false) + private LocalDateTime invitedAt; + + @Column(name = "is_accept", nullable = false) + @ColumnDefault("false") + private boolean isAccept; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/invitation/entity/InvitationStatus.java b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/InvitationStatus.java new file mode 100644 index 0000000..09101f3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/invitation/entity/InvitationStatus.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.invitation.entity; + +public enum InvitationStatus { + READ_ONLY, + EDIT_ALLOWED +} diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/controller/MeetingController.java b/src/main/java/com/teamEWSN/gitdeun/meeting/controller/MeetingController.java new file mode 100644 index 0000000..9241da3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/controller/MeetingController.java @@ -0,0 +1,4 @@ +package com.teamEWSN.gitdeun.meeting.controller; + +public class MeetingController { +} diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/dto/MeetingResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/meeting/dto/MeetingResponseDto.java new file mode 100644 index 0000000..0e4edf7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/dto/MeetingResponseDto.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.meeting.dto; + +public class MeetingResponseDto { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java new file mode 100644 index 0000000..7f58fe4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Meeting.java @@ -0,0 +1,46 @@ +package com.teamEWSN.gitdeun.meeting.entity; + +import com.teamEWSN.gitdeun.common.util.CreatedEntity; +import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "meeting") +public class Meeting extends CreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "node_id", nullable = false) + private MindmapNode node; + + @Column(name = "room_name", length = 255, nullable = false) + private String roomName; + + @Column(length = 255) + private String title; + + @Column(columnDefinition = "TEXT") + private String summary; + + @Column(name = "shared_to_ai", nullable = false) + @ColumnDefault("false") + private boolean sharedToAI; + + @Column(name = "started_at", nullable = false) + private LocalDateTime startedAt; + + @Column(name = "ended_at") + private LocalDateTime endedAt; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java new file mode 100644 index 0000000..bcb8b40 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/Participant.java @@ -0,0 +1,36 @@ +package com.teamEWSN.gitdeun.meeting.entity; + +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "participant") +@IdClass(ParticipantId.class) +public class Participant { + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Id + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "meeting_id", nullable = false) + private Meeting meeting; + + @CreationTimestamp + @Column(name = "joined_at", nullable = false, updatable = false) + private LocalDateTime joinedAt; + + @Column(name = "left_at") + private LocalDateTime leftAt; + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/entity/ParticipantId.java b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/ParticipantId.java new file mode 100644 index 0000000..673a00f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/entity/ParticipantId.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.meeting.entity; + +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@NoArgsConstructor +@EqualsAndHashCode +public class ParticipantId implements Serializable { + private Long user; + private Long meeting; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/repository/MeetingRepository.java b/src/main/java/com/teamEWSN/gitdeun/meeting/repository/MeetingRepository.java new file mode 100644 index 0000000..c38249a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/repository/MeetingRepository.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.meeting.repository; + +public class MeetingRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/meeting/service/MeetingService.java b/src/main/java/com/teamEWSN/gitdeun/meeting/service/MeetingService.java new file mode 100644 index 0000000..4f11b95 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/meeting/service/MeetingService.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.meeting.service; + +public class MeetingService { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java index 10fedef..09f6c16 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/controller/MindmapController.java @@ -1,5 +1,63 @@ package com.teamEWSN.gitdeun.mindmap.controller; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.service.MindmapService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController("/api/mindmaps") +@RequiredArgsConstructor public class MindmapController { - + + private final MindmapService mindmapService; + + + // 마인드맵 생성 (마인드맵에 한해서 owner 권한 얻음) + @PostMapping + public ResponseEntity createMindmap( + @RequestBody MindmapCreateRequestDto request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + MindmapResponseDto responseDto = mindmapService.createMindmap(request, userDetails.getId()); + return ResponseEntity.status(HttpStatus.CREATED).body(responseDto); + } + + // 마인드맵 상세 조회 (유저 인가 확인필요?) + @GetMapping("/{mapId}") + public ResponseEntity getMindmap( + @PathVariable Long mapId + ) { + MindmapDetailResponseDto responseDto = mindmapService.getMindmap(mapId); + return ResponseEntity.ok(responseDto); + } + + // 마인드맵 새로고침 + @PostMapping("/{mapId}/refresh") + public ResponseEntity refreshMindmap( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + MindmapDetailResponseDto responseDto = mindmapService.refreshMindmap(mapId, userDetails.getId()); + return ResponseEntity.ok(responseDto); + } + + // 마인드맵 삭제 (owner만) + @DeleteMapping("/{mapId}") + public ResponseEntity deleteMindmap( + @PathVariable Long mapId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + mindmapService.deleteMindmap(mapId, userDetails.getId()); + return ResponseEntity.ok().build(); // 성공 시 200 OK와 빈 body 반환 + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java new file mode 100644 index 0000000..2b3818d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapCreateRequestDto.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class MindmapCreateRequestDto { + private String repoUrl; + private String prompt; + private MindmapType type; + + private String field; // Optional, 'CHECK' 타입일 때 사용자가 입력하는 제목 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java new file mode 100644 index 0000000..940e119 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDetailResponseDto.java @@ -0,0 +1,22 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class MindmapDetailResponseDto { + private Long mindmapId; + private String field; // 제목 ("개발용", "확인용(n)" 등) + private MindmapType type; + private String branch; + private String prompt; + private String mapData; // 핵심 데이터인 마인드맵 JSON + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDto.java deleted file mode 100644 index b22ab69..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.mindmap.dto; - -public class MindmapDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java new file mode 100644 index 0000000..c47e190 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/dto/MindmapResponseDto.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.mindmap.dto; + +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +@AllArgsConstructor +public class MindmapResponseDto { + private Long mindmapId; + private Long repoId; + private MindmapType type; + private String field; + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java index fa5027d..31eb2a8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/Mindmap.java @@ -1,5 +1,52 @@ package com.teamEWSN.gitdeun.mindmap.entity; -public class Mindmap { - +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "mindmap") +public class Mindmap extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "repo_id", nullable = false) + private Repo repo; + + // owner + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(columnDefinition = "TEXT") + private String prompt; + + @Column(length = 100, nullable = false) + private String branch; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private MindmapType type; + + @Column(name = "Field", length = 255, nullable = false) + private String field; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "map_data", columnDefinition = "json", nullable = false) + private String mapData; + + public void updateMapData(String newMapData) { + this.mapData = newMapData; + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java new file mode 100644 index 0000000..dfeefec --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/entity/MindmapType.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.mindmap.entity; + +public enum MindmapType { + DEV, + CHECK +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java new file mode 100644 index 0000000..1bc8429 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/mapper/MindmapMapper.java @@ -0,0 +1,18 @@ +package com.teamEWSN.gitdeun.mindmap.mapper; + +import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface MindmapMapper { + + @Mapping(source = "id", target = "mindmapId") + MindmapResponseDto toResponseDto(Mindmap mindmap); + + @Mapping(source = "id", target = "mindmapId") + MindmapDetailResponseDto toDetailResponseDto(Mindmap mindmap); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java index d866bea..649338b 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/repository/MindmapRepository.java @@ -1,5 +1,22 @@ package com.teamEWSN.gitdeun.mindmap.repository; -public class MindmapRepository { - +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface MindmapRepository extends JpaRepository { + + // 사용자가 생성한 확인용 마인드맵 중 가장 최근에 생성된 것(repo 무관) + @Query("SELECT m FROM Mindmap m " + + "WHERE m.user = :user AND m.type = 'CHECK' " + + "ORDER BY m.createdAt DESC LIMIT 1") + Optional findTopByUserAndTypeOrderByCreatedAtDesc( + @Param("user") User user + ); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java index 280bcc2..88feaf0 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapService.java @@ -1,5 +1,172 @@ package com.teamEWSN.gitdeun.mindmap.service; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapCreateRequestDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; +import com.teamEWSN.gitdeun.mindmap.dto.MindmapResponseDto; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import com.teamEWSN.gitdeun.mindmap.entity.MindmapType; +import com.teamEWSN.gitdeun.mindmap.mapper.MindmapMapper; +import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; +import com.teamEWSN.gitdeun.mindmap.repository.MindmapRepository; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.repo.repository.RepoRepository; +import com.teamEWSN.gitdeun.repo.service.RepoService; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +@Service +@RequiredArgsConstructor public class MindmapService { - + + private final VisitHistoryService visitHistoryService; + private final RepoService repoService; + private final MindmapMapper mindmapMapper; + private final MindmapRepository mindmapRepository; + private final MindmapMemberRepository mindmapMemberRepository; + private final RepoRepository repoRepository; + private final UserRepository userRepository; + private final FastApiClient fastApiClient; + + @Transactional + public MindmapResponseDto createMindmap(MindmapCreateRequestDto req, Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + AnalysisResultDto dto = fastApiClient.analyze(req.getRepoUrl(), req.getPrompt(), req.getType()); + + Repo repo = repoService.createOrUpdate(req.getRepoUrl(), dto); + repoRepository.save(repo); + + String field; + if (req.getType() == MindmapType.DEV) { + field = "개발용"; + } else { + if (req.getField() != null && !req.getField().isEmpty()) { + field = req.getField(); + } else { + // findNextCheckSequence 호출 시 repo 정보 제거 + long nextSeq = findNextCheckSequence(user); + field = "확인용 (" + nextSeq + ")"; + } + } + + Mindmap mindmap = Mindmap.builder() + .repo(repo) + .user(user) + .prompt(req.getPrompt()) + .branch(dto.getDefaultBranch()) + .type(req.getType()) + .field(field) + .mapData(dto.getMapData()) + .build(); + + mindmapRepository.save(mindmap); + + // 마인드맵 소유자 등록 + mindmapMemberRepository.save( + MindmapMember.of(mindmap, user, MindmapRole.OWNER) + ); + + // 방문 기록 생성 + visitHistoryService.createVisitHistory(user, mindmap); + + return mindmapMapper.toResponseDto(mindmap); + + } + + /** + * 특정 사용자의 "확인용 (n)" 다음 시퀀스 번호를 찾습니다. + * @param user 대상 사용자 + * @return 다음 시퀀스 번호 + */ + private long findNextCheckSequence(User user) { + // repo 조건이 제거된 리포지토리 메서드 호출 + Optional lastCheckMindmap = mindmapRepository.findTopByUserAndTypeOrderByCreatedAtDesc(user); + + if (lastCheckMindmap.isEmpty()) { + return 1; + } + + Pattern pattern = Pattern.compile("\\((\\d+)\\)"); + Matcher matcher = pattern.matcher(lastCheckMindmap.get().getField()); + + if (matcher.find()) { + long lastSeq = Long.parseLong(matcher.group(1)); + return lastSeq + 1; + } + + return 1; + } + + + /** + * 마인드맵 상세 정보 조회 + */ + @Transactional + public MindmapDetailResponseDto getMindmap(Long mapId) { + Mindmap mindmap = mindmapRepository.findById(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + return mindmapMapper.toDetailResponseDto(mindmap); + } + + /** + * 마인드맵 새로고침 + */ + @Transactional + public MindmapDetailResponseDto refreshMindmap(Long mapId, Long userId) { + Mindmap mindmap = mindmapRepository.findById(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + // 마인드맵 생성자만 새로고침 가능 + if (!mindmap.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 기존 정보로 FastAPI 재호출 + AnalysisResultDto dto = fastApiClient.analyze( + mindmap.getRepo().getGithubRepoUrl(), + mindmap.getPrompt(), + mindmap.getType() + ); + + // 데이터 최신화 + mindmap.getRepo().updateWithAnalysis(dto); + mindmap.updateMapData(dto.getMapData()); // Mindmap 엔티티에 편의 메서드 추가 필요 + + return mindmapMapper.toDetailResponseDto(mindmap); + } + + /** + * 마인드맵 삭제 + */ + @Transactional + public void deleteMindmap(Long mapId, Long userId) { + Mindmap mindmap = mindmapRepository.findById(mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MINDMAP_NOT_FOUND)); + + // 마인드맵 생성자만 삭제 가능 + if (!mindmap.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + mindmapRepository.delete(mindmap); + } + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java new file mode 100644 index 0000000..f9cc934 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/controller/MindmapEdgeController.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.mindmapedge.controller; + +public class MindmapEdgeController { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java new file mode 100644 index 0000000..f7b9ab7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/dto/MindmapEdgeDto.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.mindmapedge.dto; + +public class MindmapEdgeDto { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java new file mode 100644 index 0000000..0807276 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/EdgeType.java @@ -0,0 +1,7 @@ +package com.teamEWSN.gitdeun.mindmapedge.entity; + +public enum EdgeType { + CROSS, + PARENT, + CHILD +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java new file mode 100644 index 0000000..8b96998 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/entity/MindmapEdge.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.mindmapedge.entity; + +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.mindmapnode.entity.MindmapNode; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +import java.math.BigDecimal; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mindmap_edge") +public class MindmapEdge extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "from_node_id", nullable = false) + private MindmapNode fromNode; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "to_node_id", nullable = false) + private MindmapNode toNode; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private EdgeType type; + + @Column(nullable = false) + @ColumnDefault("0") + private BigDecimal strength; + + @Column(name = "arango_key") + private Long arangoKey; + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java new file mode 100644 index 0000000..73abaf4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/repository/MindmapEdgeRepository.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.mindmapedge.repository; + +public class MindmapEdgeRepository { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java new file mode 100644 index 0000000..ab345d5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapedge/service/MindmapEdgeService.java @@ -0,0 +1,5 @@ +package com.teamEWSN.gitdeun.mindmapedge.service; + +public class MindmapEdgeService { + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java new file mode 100644 index 0000000..2e86b50 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/controller/MindmapMemberController.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.mindmapmember.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.mindmapmember.dto.RoleChangeRequestDto; +import com.teamEWSN.gitdeun.mindmapmember.service.MindmapMemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController("/api/mindmaps/{mapId}/members/{memberId}") +@RequiredArgsConstructor +public class MindmapMemberController { + + private final MindmapMemberService memberService; + + // 마인드맵 멤버 권한 변경 + @PatchMapping("/role") + public ResponseEntity updateRole( + @PathVariable Long mapId, + @PathVariable Long memberId, + @RequestBody RoleChangeRequestDto dto, + @AuthenticationPrincipal CustomUserDetails user) { + memberService.changeRole(mapId, memberId, dto.getRole(), user.getId()); + return ResponseEntity.ok().build(); + } + + // 마인드맵 멤버 추방 + @DeleteMapping + public ResponseEntity kickMember( + @PathVariable Long mapId, + @PathVariable Long memberId, + @AuthenticationPrincipal CustomUserDetails user) { + memberService.removeMember(mapId, memberId, user.getId()); + return ResponseEntity.ok().build(); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/dto/RoleChangeRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/dto/RoleChangeRequestDto.java new file mode 100644 index 0000000..c784706 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/dto/RoleChangeRequestDto.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.mindmapmember.dto; + +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class RoleChangeRequestDto { + private MindmapRole role; // OWNER, EDITOR, VIEWER +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapMember.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapMember.java new file mode 100644 index 0000000..004fcb9 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapMember.java @@ -0,0 +1,44 @@ +package com.teamEWSN.gitdeun.mindmapmember.entity; + +import com.teamEWSN.gitdeun.common.util.CreatedEntity; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mindmap_member") +public class MindmapMember extends CreatedEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + // member + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + private MindmapRole role; + + // 생성일이 멤버 수락일 + + public static MindmapMember of(Mindmap mindmap, User user, MindmapRole role) { + MindmapMember member = new MindmapMember(); + member.mindmap = mindmap; + member.user = user; + member.role = role; // OWNER‧EDITOR‧VIEWER + return member; + } + + public void updateRole(MindmapRole newRole) { + this.role = newRole; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapRole.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapRole.java new file mode 100644 index 0000000..c507e38 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/entity/MindmapRole.java @@ -0,0 +1,7 @@ +package com.teamEWSN.gitdeun.mindmapmember.entity; + +public enum MindmapRole { + OWNER, // 생성자 + EDITOR, // 수정 가능 + VIEWER; // 읽기 전용 +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java new file mode 100644 index 0000000..177c596 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/repository/MindmapMemberRepository.java @@ -0,0 +1,28 @@ +package com.teamEWSN.gitdeun.mindmapmember.repository; + +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.Optional; + +@Repository +public interface MindmapMemberRepository extends JpaRepository { + + /* OWNER/EDITOR/VIEWER 여부 */ + boolean existsByMindmapIdAndUserId(Long mindmapId, Long userId); + + boolean existsByMindmapIdAndUserIdAndRole( + Long mindmapId, Long userId, MindmapRole role); + + boolean existsByMindmapIdAndUserIdAndRoleIn( + Long mindmapId, Long userId, Collection roles); + + // 권한 변경 + Optional findByIdAndMindmapId(Long memberId, Long mindmapId); + + // OWNER가 멤버 추방 + void deleteByIdAndMindmapId(Long memberId, Long mindmapId); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java new file mode 100644 index 0000000..25ba2a0 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapAuthService.java @@ -0,0 +1,31 @@ +package com.teamEWSN.gitdeun.mindmapmember.service; + +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class MindmapAuthService { + + private final MindmapMemberRepository memberRepo; + + /** OWNER 확인 */ + public boolean isOwner(Long mapId, Long userId) { + return memberRepo.existsByMindmapIdAndUserIdAndRole(mapId, userId, MindmapRole.OWNER); + } + + /** 수정 권한(OWNER, EDITOR) */ + public boolean hasEdit(Long mapId, Long userId) { + return memberRepo.existsByMindmapIdAndUserIdAndRoleIn( + mapId, userId, List.of(MindmapRole.OWNER, MindmapRole.EDITOR)); + } + + /** 열람 권한(모든 멤버) */ + public boolean hasView(Long mapId, Long userId) { + return memberRepo.existsByMindmapIdAndUserId(mapId, userId); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java new file mode 100644 index 0000000..eab5710 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapmember/service/MindmapMemberService.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.mindmapmember.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapMember; +import com.teamEWSN.gitdeun.mindmapmember.entity.MindmapRole; +import com.teamEWSN.gitdeun.mindmapmember.repository.MindmapMemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MindmapMemberService { + + private final MindmapAuthService auth; + private final MindmapMemberRepository memberRepository; + + // 멤버 권한 변경 + @Transactional + public void changeRole(Long mapId, Long memberId, + MindmapRole newRole, Long requesterId) { + + // 호출자가 OWNER인지 확인 + if (!auth.isOwner(mapId, requesterId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + // 대상 멤버 조회 후 role 변경 + MindmapMember member = memberRepository.findByIdAndMindmapId(memberId, mapId) + .orElseThrow(() -> new GlobalException(ErrorCode.MEMBER_NOT_FOUND)); + member.updateRole(newRole); + } + + // 멤버 추방 + @Transactional + public void removeMember(Long mapId, Long memberId, Long requesterId) { + if (!auth.isOwner(mapId, requesterId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + memberRepository.deleteByIdAndMindmapId(memberId, mapId); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java index 40ae180..73a946c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmapnode/entity/MindmapNode.java @@ -1,5 +1,41 @@ package com.teamEWSN.gitdeun.mindmapnode.entity; -public class MindmapNode { - +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "mindmap_node") +public class MindmapNode extends AuditedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + @Column(length = 100, nullable = false) + private String label; + + @Column(columnDefinition = "TEXT", nullable = false) + private String path; + + @Column(nullable = false) + @ColumnDefault("1") + private Integer depth; + + @Column(name = "arango_key", length = 64) + private String arangoKey; + + @Column(name = "Importance", nullable = false) + @ColumnDefault("0") + private Double importance; } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java new file mode 100644 index 0000000..eed00d3 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/controller/RepoController.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.repo.controller; + +import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; +import com.teamEWSN.gitdeun.repo.dto.RepoUpdateCheckResponseDto; +import com.teamEWSN.gitdeun.repo.service.RepoService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/repos") +public class RepoController { + + private final RepoService repoService; + + // 리포지토리 URL을 통한 등록 확인 + @GetMapping("/check") + public ResponseEntity checkRepoExists(@RequestParam String url) { + return repoService.findRepoByUrl(url) // Optional를 받음 + .map(ResponseEntity::ok) // 값이 있으면 200 OK와 함께 body에 담아 반환 + .orElseGet(() -> ResponseEntity.noContent().build()); // 값이 없으면 204 No Content 반환 + } + + // 리포지토리 정보 조회 + @GetMapping("/{repoId}") + public ResponseEntity getRepoInfo(@PathVariable Long repoId) { + RepoResponseDto response = repoService.findRepoById(repoId); + return ResponseEntity.ok(response); + } + + /** + * 특정 리포지토리에 대한 업데이트 필요 여부 확인 + */ + @GetMapping("/{repoId}/status") + public ResponseEntity getRepoUpdateStatus(@PathVariable Long repoId) { + RepoUpdateCheckResponseDto response = repoService.checkUpdateNeeded(repoId); + return ResponseEntity.ok(response); + } + + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java new file mode 100644 index 0000000..d2eddd1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoResponseDto.java @@ -0,0 +1,18 @@ +package com.teamEWSN.gitdeun.repo.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class RepoResponseDto { + + private final Long repoId; + private final String githubRepoUrl; + private final String defaultBranch; + private final String description; + private final LocalDateTime githubLastUpdatedAt; + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java new file mode 100644 index 0000000..199cdd7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/dto/RepoUpdateCheckResponseDto.java @@ -0,0 +1,11 @@ +package com.teamEWSN.gitdeun.repo.dto; + + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class RepoUpdateCheckResponseDto { + private final boolean updateNeeded; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java new file mode 100644 index 0000000..7b3fb71 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/entity/Repo.java @@ -0,0 +1,47 @@ +package com.teamEWSN.gitdeun.repo.entity; + +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "repo") +public class Repo { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "github_repo_url", length = 512, nullable = false, unique = true) + private String githubRepoUrl; + + @Column(name = "default_branch", length = 100) + private String defaultBranch; // 기본 브랜치 + + @Column(columnDefinition = "TEXT") + private String description; // 설명 + + @Column(name = "github_last_updated_at") + private LocalDateTime githubLastUpdatedAt; // GitHub 브랜치 최신 커밋 시간 (commit.committer.date) + + + @Builder + public Repo(String githubRepoUrl, String defaultBranch, String description, LocalDateTime githubLastUpdatedAt) { + this.githubRepoUrl = githubRepoUrl; + this.defaultBranch = defaultBranch; + this.description = description; + this.githubLastUpdatedAt = githubLastUpdatedAt; + } + + public void updateWithAnalysis(AnalysisResultDto result) { + this.defaultBranch = result.getDefaultBranch(); + this.description = result.getDescription(); + this.githubLastUpdatedAt = result.getGithubLastUpdatedAt(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java b/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java new file mode 100644 index 0000000..d4f6151 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/mapper/RepoMapper.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.repo.mapper; + +import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface RepoMapper { + + @Mapping(source = "id", target = "repoId") + RepoResponseDto toResponseDto(Repo repo); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java new file mode 100644 index 0000000..2d7ec47 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/repository/RepoRepository.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.repo.repository; + +import com.teamEWSN.gitdeun.repo.entity.Repo; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RepoRepository extends JpaRepository { + + Optional findByGithubRepoUrl(String githubRepoUrl); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java new file mode 100644 index 0000000..02fbd20 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/repo/service/RepoService.java @@ -0,0 +1,74 @@ +package com.teamEWSN.gitdeun.repo.service; + +import com.teamEWSN.gitdeun.common.fastapi.FastApiClient; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.fastapi.dto.AnalysisResultDto; +import com.teamEWSN.gitdeun.repo.dto.RepoResponseDto; +import com.teamEWSN.gitdeun.repo.dto.RepoUpdateCheckResponseDto; +import com.teamEWSN.gitdeun.repo.entity.Repo; +import com.teamEWSN.gitdeun.repo.mapper.RepoMapper; +import com.teamEWSN.gitdeun.repo.repository.RepoRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) // 기본적으로는 읽기 전용, 데이터 변경 메서드에 @Transactional을 별도 추가 +public class RepoService { + + private final RepoRepository repoRepository; + private final RepoMapper repoMapper; + private final FastApiClient fastApiClient; + + // 레포지토리 ID로 정보 조회 + public RepoResponseDto findRepoById(Long repoId) { + Repo repo = repoRepository.findById(repoId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_ID)); + return repoMapper.toResponseDto(repo); + } + + // 리포지토리 URL을 통한 조회하여 등록 확인 + public Optional findRepoByUrl(String url) { + return repoRepository.findByGithubRepoUrl(url) + .map(repoMapper::toResponseDto); + } + + /** + * 리포지토리의 최신 업데이트 상태를 실시간으로 확인 + * @param repoId 확인할 리포지토리의 ID + * @return 업데이트 필요 여부를 담은 DTO + */ + public RepoUpdateCheckResponseDto checkUpdateNeeded(Long repoId) { + // 시스템의 마지막 동기화 시간 조회 + Repo repo = repoRepository.findById(repoId) + .orElseThrow(() -> new GlobalException(ErrorCode.REPO_NOT_FOUND_BY_ID)); + LocalDateTime lastSyncedAt = repo.getGithubLastUpdatedAt(); + + // FastAPI의 가벼운 API를 호출하여 GitHub의 최신 커밋 시간 조회 + LocalDateTime latestCommitAt = fastApiClient.fetchLatestCommitTime(repo.getGithubRepoUrl()); + + // 두 시간을 비교하여 업데이트 필요 여부 결정 + boolean isNeeded = latestCommitAt.isAfter(lastSyncedAt); + + return new RepoUpdateCheckResponseDto(isNeeded); + } + + // 마인드맵 생성 시 repo 생성 및 업데이트 + @Transactional + public Repo createOrUpdate(String repoUrl, AnalysisResultDto dto) { + return repoRepository.findByGithubRepoUrl(repoUrl) + .map(r -> { r.updateWithAnalysis(dto); return r; }) + .orElseGet(() -> { + Repo repo = Repo.builder() + .githubRepoUrl(repoUrl) + .build(); + repo.updateWithAnalysis(dto); + return repo; + }); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java b/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java deleted file mode 100644 index ced6177..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/controller/RepositoryController.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.controller; - -public class RepositoryController { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java b/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java deleted file mode 100644 index 603d314..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/dto/RepositoryDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.dto; - -public class RepositoryDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java b/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java deleted file mode 100644 index 2d51bbe..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/entity/Repository.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.entity; - -public class Repository { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java deleted file mode 100644 index 6abc5b4..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/repository/RepositoryRepository.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.repository; - -public class RepositoryRepository { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java b/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java deleted file mode 100644 index 28097f8..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/repository/service/RepositoryService.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.repository.service; - -public class RepositoryService { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java new file mode 100644 index 0000000..775ccb4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -0,0 +1,92 @@ +package com.teamEWSN.gitdeun.user.controller; + +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.common.oauth.dto.SocialConnectionResponseDto; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.service.OAuthStateService; +import com.teamEWSN.gitdeun.common.oauth.service.SocialTokenRefreshService; +import com.teamEWSN.gitdeun.user.dto.UserTokenResponseDto; +import com.teamEWSN.gitdeun.user.service.AuthService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + private final OAuthStateService oAuthStateService; + private final SocialTokenRefreshService socialTokenRefreshService; + private final AuthService authService; + private final CookieUtil cookieUtil; + + @Value("${app.front-url}") + private String frontUrl; + + // 깃허브 연동 흐름 구분 조회 + @GetMapping("/connect/github/state") + public ResponseEntity> generateStateForGithubConnect(@AuthenticationPrincipal CustomUserDetails user) { + String state = oAuthStateService.createState("connect:" + user.getId()); + return ResponseEntity.ok(Map.of("state", state)); + } + + // 로그아웃 API + @PostMapping("/logout") + public ResponseEntity logout( + @RequestHeader("Authorization") String authHeader, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response + ) { + // 헤더에서 Access Token 추출 + String accessToken = authHeader.replace("Bearer ", ""); + + // 로그아웃 로직 - AccessToken: Blacklist 등록, RefreshToken: redis에서 삭제 및 쿠키 제거 + authService.logout(accessToken, refreshToken, response); + + return ResponseEntity.noContent().build(); // 204 No Content 응답 + } + + // 토큰 재발급 + @GetMapping("/token/refresh") + public ResponseEntity refreshAccessToken( + @CookieValue(name = "refreshToken") String refreshToken, + HttpServletResponse response) { + JwtToken newJwtToken = authService.refreshTokens(refreshToken); + + cookieUtil.setCookie(response, "refreshToken", newJwtToken.getRefreshToken(), refreshTokenExpired); + return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); + } + + // 외부 OAuth 재발급 (Access 만료 → provider 선택) + @PostMapping("/oauth/refresh/{provider}") + public ResponseEntity refreshSocial( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable OauthProvider provider) { + + socialTokenRefreshService.refreshSocialToken(user.getId(), provider); + return ResponseEntity.noContent().build(); + } + + // socialconnection 확인용 + @GetMapping("/social") + public ResponseEntity getSocialConnections( + @AuthenticationPrincipal CustomUserDetails user + ) { + SocialConnectionResponseDto response = authService.getConnectedProviders(user.getId()); + return ResponseEntity.ok(response); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java index 4a54934..47d7c70 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java @@ -1,5 +1,53 @@ package com.teamEWSN.gitdeun.user.controller; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/users/me") +@RequiredArgsConstructor public class UserController { - + + private final UserService userService; + + // 개인 정보 조회 + @GetMapping + public ResponseEntity getMyInfo(@AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getId(); + UserResponseDto userInfo = userService.getMyInfo(userId); + + return ResponseEntity.ok(userInfo); + } + + + // 현재 회원 정보를 바탕으로 회원 탈퇴 + @DeleteMapping + public ResponseEntity deleteCurrentUser( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + @RequestHeader("Authorization") String authHeader) { + // JwtAuthenticationFilter가 정상 동작했다면 userDetails는 절대 null이 아님 + if (refreshToken == null || refreshToken.isEmpty()) { + // 리프레시 토큰이 없는 경우에 대한 예외 처리 + throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + String accessToken = authHeader.replace("Bearer ", ""); + + userService.deleteUser(userDetails.getId(), accessToken, refreshToken); + + return ResponseEntity.noContent().build(); + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java new file mode 100644 index 0000000..fe2f5ae --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserSettingController.java @@ -0,0 +1,48 @@ +package com.teamEWSN.gitdeun.user.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import com.teamEWSN.gitdeun.user.service.UserSettingService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/users/me/settings") +@RequiredArgsConstructor +public class UserSettingController { + private final UserSettingService userSettingService; + + /** + * 현재 로그인된 사용자의 설정을 조회 + * @param userDetails 인증된 사용자 정보 + * @return 현재 설정 정보를 담은 응답 + */ + @GetMapping + public ResponseEntity getUserSettings( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + UserSettingResponseDto responseDto = userSettingService.getSettings(userDetails.getId()); + return ResponseEntity.ok(responseDto); + } + + /** + * 현재 로그인된 사용자의 설정을 변경 + * @param userDetails 인증된 사용자 정보 + * @param requestDto 변경할 설정 정보를 담은 요청 DTO + * @return 변경된 설정 정보를 담은 응답 + */ + @PatchMapping + public ResponseEntity updateUserSettings( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Valid @RequestBody UserSettingUpdateRequestDto requestDto + ) { + UserSettingResponseDto responseDto = userSettingService.updateSettings(userDetails.getId(), requestDto); + return ResponseEntity.ok(responseDto); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java deleted file mode 100644 index 0d992c8..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.user.dto; - -public class UserDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java new file mode 100644 index 0000000..0d41235 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.user.dto; + +import com.teamEWSN.gitdeun.user.entity.Role; +import lombok.Builder; +import lombok.Getter; + + +@Getter +@Builder +public class UserResponseDto { + private Long userId; // 사용자 ID + private String name; // 사용자 이름 + private String email; // 이메일 + private String nickname; // 닉네임 + private String profileImage; // image url + private Role role; // 권한 (USER/ADMIN 등) + + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java new file mode 100644 index 0000000..663f577 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingResponseDto.java @@ -0,0 +1,16 @@ +package com.teamEWSN.gitdeun.user.dto; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSettingResponseDto { + private UserSetting.DisplayTheme theme; + private boolean emailNotification; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java new file mode 100644 index 0000000..d65117b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserSettingUpdateRequestDto.java @@ -0,0 +1,18 @@ +package com.teamEWSN.gitdeun.user.dto; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserSettingUpdateRequestDto { + @NotNull(message = "테마를 선택해주세요.") + private UserSetting.DisplayTheme theme; + + + @NotNull(message = "이메일 수신 여부를 선택해주세요.") + private Boolean emailNotification; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java new file mode 100644 index 0000000..97f900b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class UserTokenResponseDto { + private String accessToken; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java new file mode 100644 index 0000000..0099634 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.user.entity; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + USER, + ADMIN; + + @Override + public String getAuthority() { + return "ROLE_" + name(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 2025a52..07a5ff8 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -1,5 +1,70 @@ package com.teamEWSN.gitdeun.user.entity; -public class User { - +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.util.AuditedEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "users") +public class User extends AuditedEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 100) + private String name; + + @Column(nullable = false, length = 100, unique = true) + private String nickname; + + @Column(nullable = false, length = 256) + private String email; + + @Column(name="profile_image", length = 512) + private String profileImage; // image url + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List socialConnections = new ArrayList<>(); + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + + @Builder + public User(String name, String nickname, String email, String profileImage, Role role) { + this.name = name; + this.nickname = nickname; + this.email = email; + this.profileImage = profileImage; + this.role = role; + } + + public User updateProfile(String name, String profileImage) { + this.name = name; + this.profileImage = profileImage; + return this; // 메소드 체이닝을 위해 this 반환 + } + + // 회원 탈퇴 처리 + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + // 회원 닉네임 변경 + public void updateNickname(String newNickname) { + this.nickname = newNickname; + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java new file mode 100644 index 0000000..29a9ac2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/UserSetting.java @@ -0,0 +1,59 @@ +package com.teamEWSN.gitdeun.user.entity; + +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import jakarta.persistence.Entity; +import lombok.*; +import jakarta.persistence.*; +import org.hibernate.annotations.ColumnDefault; + +@Entity +@Getter +@Builder +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "user_settings") +public class UserSetting { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + // 화면 테마 (LIGHT, DARK) + @Builder.Default + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + @ColumnDefault("'LIGHT'") + private DisplayTheme theme = DisplayTheme.LIGHT; + + @Builder.Default + @Column(name = "email_notification", nullable = false) + @ColumnDefault("true") + private boolean emailNotification = true; + + + public enum DisplayTheme { + LIGHT, DARK + } + + @Builder + private UserSetting(User user, DisplayTheme theme, boolean emailNotification) { + this.user = user; + this.theme = theme; + this.emailNotification = emailNotification; + } + + public static UserSetting createDefault(User user) { + return UserSetting.builder() + .user(user) + .build(); + } + + public void update(UserSettingUpdateRequestDto dto) { + this.theme = dto.getTheme(); + this.emailNotification = dto.getEmailNotification(); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java new file mode 100644 index 0000000..b3939e8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.user.mapper; + +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.user.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface UserMapper { + + @Mapping(source = "id", target = "userId") + UserResponseDto toResponseDto(User user); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java new file mode 100644 index 0000000..916e774 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserSettingMapper.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.user.mapper; + +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface UserSettingMapper { + + UserSettingResponseDto toResponseDto(UserSetting userSetting); + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java index cdeeab9..3f4a861 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java @@ -1,5 +1,19 @@ package com.teamEWSN.gitdeun.user.repository; -public class UserRepository { - +import com.teamEWSN.gitdeun.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + // user id로 검색 + Optional findByIdAndDeletedAtIsNull(Long id); + + // user email로 검색 + Optional findByEmailAndDeletedAtIsNull(String email); + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java new file mode 100644 index 0000000..fb0228a --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserSettingRepository.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.user.repository; + +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserSettingRepository extends JpaRepository { + + // 사용자 ID로 설정 조회 + Optional findByUserId(Long userId); +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java new file mode 100644 index 0000000..34ef673 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -0,0 +1,126 @@ +package com.teamEWSN.gitdeun.user.service; + + +import com.teamEWSN.gitdeun.common.jwt.RefreshToken; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.*; +import com.teamEWSN.gitdeun.common.oauth.dto.SocialConnectionResponseDto; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + private final BlacklistService blacklistService; + private final UserService userService; + private final CookieUtil cookieUtil; + private final UserRepository userRepository; + private final SocialConnectionRepository socialConnectionRepository; + + // 로그 아웃 + @Transactional + public void logout(String accessToken, String refreshToken, HttpServletResponse response) { + blacklistService.addToBlacklist(accessToken); + refreshTokenService.deleteRefreshToken(refreshToken); + cookieUtil.deleteCookie(response, "refreshToken"); + } + + // 토큰 재발급 + @Transactional + public JwtToken refreshTokens(String refreshToken) { + // Redis에서 Refresh Token 조회 + RefreshToken tokenDetails = refreshTokenService.getRefreshToken(refreshToken) + .orElseThrow(() -> new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN)); + + // 토큰에서 email 정보 추출 + Long userId = tokenDetails.getUserId(); + + // userId로 사용자 정보 조회 + User user = userService.findById(userId); + Authentication authentication = createAuthentication(user); + + // 기존 리프레시 토큰은 DB에서 제거 (순환) + refreshTokenService.deleteRefreshToken(refreshToken); + + // 새로운 Access/Refresh 토큰 생성 + return jwtTokenProvider.generateToken(authentication); + } + + // User 객체를 사용해 authentication 생성 + private Authentication createAuthentication(User user) { + CustomUserDetails customUserDetails = new CustomUserDetails( + user.getId(), user.getEmail(), user.getNickname(), user.getProfileImage(), user.getRole(), user.getName()); + + return new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + } + + + // 구글 -> Github 계정 연동 + public void connectGithubAccount(OAuth2User githubUser, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + String providerId = Objects.requireNonNull(githubUser.getAttribute("id")).toString(); + String accessToken = githubUser.getAttribute("access_token"); + String refreshToken = githubUser.getAttribute("refresh_token"); + + // 이미 다른 계정에 연동되어 있는지, 동일 사용자에 의해 이미 연동되는지 확인 + socialConnectionRepository.findByProviderAndProviderId(OauthProvider.GITHUB, providerId) + .ifPresent(connection -> { + if (!connection.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); + } else { + log.info("이미 현재 사용자와 연동된 GitHub 계정입니다: {}", providerId); + } + }); + + + // 신규 소셜 연동 정보 생성 및 저장 + SocialConnection connection = SocialConnection.builder() + .user(user) + .provider(OauthProvider.GITHUB) + .providerId(providerId) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + + socialConnectionRepository.save(connection); + } + + // 사용자의 모든 소셜 연동 정보를 조회 + @Transactional(readOnly = true) + public SocialConnectionResponseDto getConnectedProviders(Long userId) { + // userId로 사용자를 조회 + User user = userService.findById(userId); + + // 사용자의 SocialConnection 리스트에서 Provider 정보만 추출하여 리스트 생성 + List providers = user.getSocialConnections().stream() + .map(SocialConnection::getProvider) + .collect(Collectors.toList()); + + // DTO에 담아 반환 변환 + return new SocialConnectionResponseDto(providers); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java index 45bcdea..133620d 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java @@ -1,5 +1,112 @@ package com.teamEWSN.gitdeun.user.service; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.jwt.BlacklistService; +import com.teamEWSN.gitdeun.common.jwt.RefreshTokenService; +import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; +import com.teamEWSN.gitdeun.common.oauth.service.GoogleApiHelper; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.user.entity.Role; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.mapper.UserMapper; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor public class UserService { - + private final UserMapper userMapper; + private final RefreshTokenService refreshTokenService; + private final BlacklistService blacklistService; + private final UserRepository userRepository; + private final GoogleApiHelper googleApiHelper; + private final GitHubApiHelper gitHubApiHelper; + + // 회원 정보 조회 + @Transactional(readOnly = true) + public UserResponseDto getMyInfo(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + return userMapper.toResponseDto(user); + } + + + // 로그인된 회원 탈퇴 처리 + @Transactional + public void deleteUser(Long userId, String accessToken, String refreshToken) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + List connections = user.getSocialConnections(); + + // 모든 소셜 연동 해제 시도 + for (SocialConnection connection : connections) { + try { + switch (connection.getProvider()) { + case GOOGLE: + googleApiHelper.revokeToken(connection.getAccessToken()).block(); + log.info("Google token for user {} has been revoked.", userId); + break; + case GITHUB: + gitHubApiHelper.revokeToken(connection.getAccessToken()).block(); + log.info("GitHub token for user {} has been revoked.", userId); + break; + default: + log.warn("Unsupported provider for token revocation: {}", connection.getProvider()); + } + } catch (Exception e) { + // 특정 플랫폼 연동 해제에 실패하더라도, 다른 플랫폼 및 DB 처리는 계속 진행하기 위해 로그만 남김 + log.error("Failed to revoke token for provider {} and user {}: {}", + connection.getProvider(), userId, e.getMessage()); + } + } + + // redis에서 리프레시 토큰 삭제 + refreshTokenService.deleteRefreshToken(refreshToken); + // access token 블랙리스트에 등록 + blacklistService.addToBlacklist(accessToken); + + // 깃든 서비스 DB에서 soft-delete 처리 + user.markAsDeleted(); + userRepository.save(user); + log.info("User {} has been marked as deleted.", userId); + } + + // 이메일로 회원 검색 + @Transactional(readOnly = true) + public User findUserByEmail(String email) { + return userRepository.findByEmailAndDeletedAtIsNull(email) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_EMAIL)); + + } + + // 아이디로 회원 검색 + @Transactional(readOnly = true) + public User findById(Long id) { + return userRepository.findByIdAndDeletedAtIsNull(id) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + } + + @Transactional + public Long upsertAndGetId(String email, String name, String picture, String nickname) { + return userRepository.findByEmailAndDeletedAtIsNull(email) + .map(u -> u.updateProfile(name, picture)) // 이미 있으면 갱신 + .orElseGet(() -> userRepository.save( + User.builder() + .email(email).name(name).profileImage(picture) + .nickname(nickname) + .role(Role.USER) + .build())) + .getId(); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java new file mode 100644 index 0000000..b812700 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserSettingService.java @@ -0,0 +1,61 @@ +package com.teamEWSN.gitdeun.user.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.user.dto.UserSettingResponseDto; +import com.teamEWSN.gitdeun.user.dto.UserSettingUpdateRequestDto; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.entity.UserSetting; +import com.teamEWSN.gitdeun.user.mapper.UserSettingMapper; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.user.repository.UserSettingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserSettingService { + + private final UserSettingRepository userSettingRepository; + private final UserRepository userRepository; + private final UserSettingMapper userSettingMapper; + + /** + * 사용자 ID로 설정 정보 조회 + * 설정이 없는 경우 기본값을 생성하고 저장한 뒤 반환합니다. + * @param userId 조회할 사용자의 ID + * @return 사용자의 설정 정보 DTO + */ + @Transactional + public UserSettingResponseDto getSettings(Long userId) { + UserSetting userSetting = userSettingRepository.findByUserId(userId) + .orElseGet(() -> { + // 설정이 없는 경우, 기본 설정을 생성 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + UserSetting defaultSetting = UserSetting.createDefault(user); + return userSettingRepository.save(defaultSetting); + }); + + return userSettingMapper.toResponseDto(userSetting); + } + + /** + * 사용자 설정 업데이트 + * @param userId 업데이트할 사용자의 ID + * @param requestDto 업데이트할 설정 내용 + * @return 업데이트된 사용자의 설정 정보 DTO + */ + @Transactional + public UserSettingResponseDto updateSettings(Long userId, UserSettingUpdateRequestDto requestDto) { + UserSetting userSetting = userSettingRepository.findByUserId(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_SETTING_NOT_FOUND_BY_ID)); + + // 엔티티의 update 메서드를 사용하여 상태 변경 + userSetting.update(requestDto); + + return userSettingMapper.toResponseDto(userSetting); + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java new file mode 100644 index 0000000..d21df1d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/PinnedHistoryController.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.visithistory.controller; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.visithistory.service.PinnedHistoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/history/{historyId}/mindmaps/pinned") +@RequiredArgsConstructor +public class PinnedHistoryController { + + private final PinnedHistoryService pinnedHistoryService; + + // 핀 고정 + @PostMapping + public ResponseEntity fixPinned( + @PathVariable("historyId") Long historyId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + pinnedHistoryService.fixPinned(historyId, customUserDetails.getId()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + // 핀 해제 + @DeleteMapping + public ResponseEntity removePinned( + @PathVariable("historyId") Long historyId, + @AuthenticationPrincipal CustomUserDetails customUserDetails + ) { + pinnedHistoryService.removePinned(historyId, customUserDetails.getId()); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java index b4628cd..260ee09 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/controller/VisitHistoryController.java @@ -1,5 +1,50 @@ package com.teamEWSN.gitdeun.visithistory.controller; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; +import com.teamEWSN.gitdeun.visithistory.service.VisitHistoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Slf4j +@RestController +@RequestMapping("/api/history") +@RequiredArgsConstructor public class VisitHistoryController { - + + private final VisitHistoryService visitHistoryService; + + // 핀 고정되지 않은 방문 기록 조회 + @GetMapping("/visits") + public ResponseEntity> getVisitHistories( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + List histories = visitHistoryService.getVisitHistories(userDetails.getId()); + return ResponseEntity.ok(histories); + } + + // 핀 고정된 방문 기록 조회 + @GetMapping("/pins") + public ResponseEntity> getPinnedHistories( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + List histories = visitHistoryService.getPinnedHistories(userDetails.getId()); + return ResponseEntity.ok(histories); + } + + // 방문 기록 삭제 + @DeleteMapping("/visits/{historyId}") + public ResponseEntity deleteVisitHistory( + @PathVariable Long historyId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + visitHistoryService.deleteVisitHistory(historyId, userDetails.getId()); + return ResponseEntity.ok().build(); + } + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryDto.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryDto.java deleted file mode 100644 index d3b8424..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.visithistory.dto; - -public class VisitHistoryDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java new file mode 100644 index 0000000..33b7796 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/dto/VisitHistoryResponseDto.java @@ -0,0 +1,16 @@ +package com.teamEWSN.gitdeun.visithistory.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class VisitHistoryResponseDto { + private Long visitHistoryId; + private Long mindmapId; + private String mindmapField; // 마인드맵 제목 + private String repoUrl; + private LocalDateTime lastVisitedAt; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java new file mode 100644 index 0000000..a4c5402 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/PinnedHistory.java @@ -0,0 +1,40 @@ +package com.teamEWSN.gitdeun.visithistory.entity; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.teamEWSN.gitdeun.common.util.CreatedEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "pinned_history", indexes = { + @Index(name = "idx_pinnedHistory_user_visit_history", columnList = "user_id, visit_history_id", unique = true), // 주요 조회 조건 및 중복 방지 + @Index(name = "idx_pinnedHistory_visit_history_id", columnList = "visit_history_id") // 방문 기록 기준 핀 고정 목록 조회 +}) +public class PinnedHistory extends CreatedEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @JsonIgnore + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "visit_history_id", nullable = false) + private VisitHistory visitHistory; + + @Builder + public PinnedHistory(User user, VisitHistory visitHistory) { + this.user = user; + this.visitHistory = visitHistory; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java index 68b7c40..ef50958 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/entity/VisitHistory.java @@ -1,5 +1,40 @@ package com.teamEWSN.gitdeun.visithistory.entity; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.ColumnDefault; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "visit_history") public class VisitHistory { - + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "mindmap_id", nullable = false) + private Mindmap mindmap; + + @Column(name = "last_visited_at", nullable = false) + private LocalDateTime lastVisitedAt; + + @Builder.Default + @OneToMany(mappedBy = "visitHistory", cascade = CascadeType.ALL, orphanRemoval = true) + private List pinnedHistorys = new ArrayList<>(); + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java new file mode 100644 index 0000000..d784911 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/mapper/VisitHistoryMapper.java @@ -0,0 +1,17 @@ +package com.teamEWSN.gitdeun.visithistory.mapper; + +import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; +import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface VisitHistoryMapper { + + @Mapping(source = "id", target = "visitHistoryId") + @Mapping(source = "mindmap.id", target = "mindmapId") + @Mapping(source = "mindmap.field", target = "mindmapField") + @Mapping(source = "mindmap.repo.githubRepoUrl", target = "repoUrl") + VisitHistoryResponseDto toResponseDto(VisitHistory visitHistory); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java new file mode 100644 index 0000000..ff47d84 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/PinnedHistoryRepository.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.visithistory.repository; + +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PinnedHistoryRepository extends JpaRepository { + + boolean existsByUserIdAndVisitHistoryId(Long userId, Long historyId); + + Optional findByUserIdAndVisitHistoryId(Long userId, Long historyId); + + // 사용자의 핀 고정 기록 최신순 조회 + List findByUserOrderByCreatedAtDesc(User user); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java index a03ff56..ee928c6 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/repository/VisitHistoryRepository.java @@ -1,5 +1,20 @@ package com.teamEWSN.gitdeun.visithistory.repository; -public class VisitHistoryRepository { - +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface VisitHistoryRepository extends JpaRepository { + + // 사용자의 핀 고정되지 않은 방문 기록을 최신순으로 조회 + @Query("SELECT v FROM VisitHistory v LEFT JOIN v.pinnedHistorys p " + + "WHERE v.user = :user AND p IS NULL " + + "ORDER BY v.lastVisitedAt DESC") + List findUnpinnedHistoriesByUser(@Param("user") User user); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java new file mode 100644 index 0000000..69c9df7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/PinnedHistoryService.java @@ -0,0 +1,57 @@ +package com.teamEWSN.gitdeun.visithistory.service; + +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; +import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +import com.teamEWSN.gitdeun.visithistory.repository.PinnedHistoryRepository; +import com.teamEWSN.gitdeun.visithistory.repository.VisitHistoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.teamEWSN.gitdeun.common.exception.ErrorCode.*; + + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class PinnedHistoryService { + + private final PinnedHistoryRepository pinnedHistoryRepository; + private final UserRepository userRepository; + private final VisitHistoryRepository visitHistoryRepository; + + public void fixPinned(Long historyId, Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(USER_NOT_FOUND_FIX_PIN)); + + VisitHistory visitHistory = visitHistoryRepository.findById(historyId) + .orElseThrow(() -> new GlobalException(HISTORY_NOT_FOUND)); + + // 이미 핀 고정이 있는지 확인 + if (pinnedHistoryRepository.existsByUserIdAndVisitHistoryId(userId, historyId)) { + throw new GlobalException(PINNEDHISTORY_ALREADY_EXISTS); + } + + PinnedHistory pin = PinnedHistory.builder() + .user(user) + .visitHistory(visitHistory) + .build(); + + pinnedHistoryRepository.save(pin); + + } + + @Transactional + public void removePinned(Long historyId, Long userId) { + PinnedHistory pin = pinnedHistoryRepository.findByUserIdAndVisitHistoryId(userId, historyId) + .orElseThrow(() -> new GlobalException(PINNEDHISTORY_NOT_FOUND)); + + pinnedHistoryRepository.delete(pin); + + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java index b655d9f..30a914f 100644 --- a/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java +++ b/src/main/java/com/teamEWSN/gitdeun/visithistory/service/VisitHistoryService.java @@ -1,5 +1,76 @@ package com.teamEWSN.gitdeun.visithistory.service; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.mindmap.entity.Mindmap; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.service.UserService; +import com.teamEWSN.gitdeun.visithistory.dto.VisitHistoryResponseDto; +import com.teamEWSN.gitdeun.visithistory.entity.PinnedHistory; +import com.teamEWSN.gitdeun.visithistory.entity.VisitHistory; +import com.teamEWSN.gitdeun.visithistory.mapper.VisitHistoryMapper; +import com.teamEWSN.gitdeun.visithistory.repository.PinnedHistoryRepository; +import com.teamEWSN.gitdeun.visithistory.repository.VisitHistoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor public class VisitHistoryService { - + + private final UserService userService; + private final VisitHistoryRepository visitHistoryRepository; + private final PinnedHistoryRepository pinnedHistoryRepository; + private final VisitHistoryMapper visitHistoryMapper; + + // 마인드맵 생성 시 호출되어 방문 기록을 생성 + @Transactional + public void createVisitHistory(User user, Mindmap mindmap) { + VisitHistory visitHistory = VisitHistory.builder() + .user(user) + .mindmap(mindmap) + .lastVisitedAt(LocalDateTime.now()) + .build(); + visitHistoryRepository.save(visitHistory); + } + + // 핀 고정되지 않은 방문 기록 조회 + @Transactional(readOnly = true) + public List getVisitHistories(Long userId) { + User user = userService.findById(userId); + List histories = visitHistoryRepository.findUnpinnedHistoriesByUser(user); + return histories.stream() + .map(visitHistoryMapper::toResponseDto) + .collect(Collectors.toList()); + } + + // 핀 고정된 방문 기록 조회 + @Transactional(readOnly = true) + public List getPinnedHistories(Long userId) { + User user = userService.findById(userId); + List pinnedHistories = pinnedHistoryRepository.findByUserOrderByCreatedAtDesc(user); + return pinnedHistories.stream() + .map(pinned -> visitHistoryMapper.toResponseDto(pinned.getVisitHistory())) + .collect(Collectors.toList()); + } + + // 방문 기록 삭제 + @Transactional + public void deleteVisitHistory(Long visitHistoryId, Long userId) { + VisitHistory visitHistory = visitHistoryRepository.findById(visitHistoryId) + .orElseThrow(() -> new GlobalException(ErrorCode.HISTORY_NOT_FOUND)); + + if (!visitHistory.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); + } + + visitHistoryRepository.delete(visitHistory); + } + + } \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 551cef6..37d7e1b 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -4,12 +4,33 @@ spring: data: redis: host: localhost + security: + oauth2: + client: + registration: + google: + redirect-uri: http://localhost:8080/login/oauth2/code/google + github: + client-id: ${GITHUB_DEV_CLIENT_ID} + client-secret: ${GITHUB_DEV_CLIENT_SECRET} + redirect-uri: http://localhost:8080/login/oauth2/code/github +# provider: +# google: +# authorization-uri: https://accounts.google.com/o/oauth2/v2/auth +# token-uri: https://oauth2.googleapis.com/token +# user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo +# user-name-attribute: sub + jwt: access-expired: 28800 # 8시간 refresh-expired: 86400 # 1일 app: + front-url: http://localhost:5173 cookie: secure: false - same-site: Lax \ No newline at end of file + same-site: Lax + +fastapi: + base-url: http://localhost:8000 # 내 PC에서 개발할 때의 주소 \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index ccd364f..cf65f9a 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,6 +8,16 @@ spring: redis: host: gitdeun-redis password: ${REDIS_PASSWORD} + security: + oauth2: + client: + registration: + google: + redirect-uri: https://api.gitdeun.site/login/oauth2/code/google + github: + client-id: ${GITHUB_PROD_CLIENT_ID} + client-secret: ${GITHUB_PROD_CLIENT_SECRET} + redirect-uri: https://api.gitdeun.site/login/oauth2/code/github jwt: @@ -15,6 +25,10 @@ jwt: refresh-expired: 604800 # 7일(초 단위: 7 * 24 * 60 * 60) app: + front-url: https://gitdeun.site cookie: secure: true - same-site: None \ No newline at end of file + same-site: None + +fastapi: + base-url: http://fastapi-server:8000 # Docker 네트워크 내부에서 사용할 주소 \ No newline at end of file diff --git a/src/main/resources/application-s3Bucket.yml b/src/main/resources/application-s3Bucket.yml index 74b910e..86a1525 100644 --- a/src/main/resources/application-s3Bucket.yml +++ b/src/main/resources/application-s3Bucket.yml @@ -1,12 +1,15 @@ -aws: - s3: - bucket: - stack.auto: false - name: gitdeun - region: ap-northeast-2 - credentials: - accessKey: ${S3_ACCESS_KEY} - secretAccessKey: ${S3_SECRET_KEY} +cloud: + aws: + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ap-northeast-2 + stack: + auto: false # CloudFormation 스택 자동 생성을 비활성화 + s3: + bucket: + name: gitdeun spring: config: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5873ef7..fff3e2f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,26 +9,38 @@ spring: password: ${MYSQL_PASSWORD} hikari: connection-timeout: 30000 # 30초 연결 제한 -# h2: -# console: -# enabled: true # H2 웹 콘솔 활성화 -# path: /h2-console # H2 콘솔 URL 경로 (기본값: /h2-console) + # h2: + # console: + # enabled: true # H2 웹 콘솔 활성화 + # path: /h2-console # H2 콘솔 URL 경로 (기본값: /h2-console) jpa: show-sql: true # SQL 로그 출력 hibernate: ddl-auto: create # create # update properties: hibernate: - dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true # SQL 로그를 보기 좋게 포맷 auto_quote_keyword: true # 예약어를 자동으로 따옴표 처리 order_inserts: true order_updates: true jdbc: batch_size: 1000 + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: openid, email, profile + github: + scope: user:email, repo profiles: active: dev, s3Bucket # logback-spring SpringProfile 설정 및 AWS S3 Bucket 설정 +db: + crypto-key: ${CRYPTO_KEY} jwt: + issuer: Gitdeun.site secret-key: ${JWT_SECRET_KEY}