From 512020d6bbb9db22e8a818931070b7169ab04685 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Mar 2026 13:54:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B0=B1=EC=97=94=EB=93=9C=20Spring=20?= =?UTF-8?q?Boot=20+=20DB=20=EC=8A=A4=ED=82=A4=EB=A7=88=20+=20Python=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=EC=84=9C=EB=B2=84=20=EC=8A=A4=EC=BC=88?= =?UTF-8?q?=EB=A0=88=ED=86=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/: Spring Boot 3.2 + Java 17, Google OAuth + JWT 인증 - AuthController/Service/Filter: id_token 검증 + gcsc.co.kr 도메인 제한 - JPA Entity: users, login_history - 수집기 placeholder: GDELT, Google News, CENTCOM, Aircraft - ArticleClassifier: 프론트엔드 분류 정규식 이식 - 프로파일: local / prod (PostgreSQL 211.208.115.83:5432/kcgdb) - database/: 초기 스키마 (events, news, osint, users, login_history) - prediction/: FastAPI placeholder (향후 해양 분석) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/.sdkmanrc | 1 + backend/pom.xml | 106 ++++++++++++++ .../main/java/gc/mda/kcg/KcgApplication.java | 14 ++ .../java/gc/mda/kcg/auth/AuthController.java | 77 +++++++++++ .../main/java/gc/mda/kcg/auth/AuthFilter.java | 70 ++++++++++ .../java/gc/mda/kcg/auth/AuthService.java | 130 ++++++++++++++++++ .../java/gc/mda/kcg/auth/JwtProvider.java | 68 +++++++++ .../gc/mda/kcg/auth/dto/AuthResponse.java | 17 +++ .../mda/kcg/auth/dto/GoogleAuthRequest.java | 15 ++ .../gc/mda/kcg/auth/entity/LoginHistory.java | 38 +++++ .../java/gc/mda/kcg/auth/entity/User.java | 44 ++++++ .../repository/LoginHistoryRepository.java | 7 + .../kcg/auth/repository/UserRepository.java | 11 ++ .../mda/kcg/collector/ArticleClassifier.java | 39 ++++++ .../mda/kcg/collector/CentcomCollector.java | 11 ++ .../gc/mda/kcg/collector/GdeltCollector.java | 11 ++ .../kcg/collector/GoogleNewsCollector.java | 11 ++ .../java/gc/mda/kcg/config/AppProperties.java | 36 +++++ .../gc/mda/kcg/config/SecurityConfig.java | 19 +++ .../java/gc/mda/kcg/config/WebConfig.java | 18 +++ .../mda/kcg/domain/aircraft/package-info.java | 4 + .../gc/mda/kcg/domain/event/package-info.java | 4 + .../gc/mda/kcg/domain/news/package-info.java | 4 + .../gc/mda/kcg/domain/osint/package-info.java | 4 + .../resources/application-local.yml.example | 13 ++ .../resources/application-prod.yml.example | 13 ++ backend/src/main/resources/application.yml | 11 ++ database/init.sql | 2 + database/migration/001_initial_schema.sql | 83 +++++++++++ prediction/README.md | 17 +++ prediction/main.py | 8 ++ prediction/requirements.txt | 2 + 32 files changed, 908 insertions(+) create mode 100644 backend/.sdkmanrc create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/gc/mda/kcg/KcgApplication.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/AuthController.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/AuthService.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/entity/User.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/config/AppProperties.java create mode 100644 backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java create mode 100644 backend/src/main/java/gc/mda/kcg/config/WebConfig.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/event/package-info.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/news/package-info.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java create mode 100644 backend/src/main/resources/application-local.yml.example create mode 100644 backend/src/main/resources/application-prod.yml.example create mode 100644 backend/src/main/resources/application.yml create mode 100644 database/init.sql create mode 100644 database/migration/001_initial_schema.sql create mode 100644 prediction/README.md create mode 100644 prediction/main.py create mode 100644 prediction/requirements.txt diff --git a/backend/.sdkmanrc b/backend/.sdkmanrc new file mode 100644 index 0000000..a4417cd --- /dev/null +++ b/backend/.sdkmanrc @@ -0,0 +1 @@ +java=17.0.18-amzn diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..c99e425 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,106 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + gc.mda + kcg + 0.0.1-SNAPSHOT + kcg + KCG Monitoring Dashboard Backend + + + 17 + 0.12.6 + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.postgresql + postgresql + runtime + + + + + org.projectlombok + lombok + true + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + com.google.api-client + google-api-client + 2.7.0 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + kcg + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/backend/src/main/java/gc/mda/kcg/KcgApplication.java b/backend/src/main/java/gc/mda/kcg/KcgApplication.java new file mode 100644 index 0000000..8c11f99 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/KcgApplication.java @@ -0,0 +1,14 @@ +package gc.mda.kcg; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class KcgApplication { + + public static void main(String[] args) { + SpringApplication.run(KcgApplication.class, args); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthController.java b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java new file mode 100644 index 0000000..655088c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java @@ -0,0 +1,77 @@ +package gc.mda.kcg.auth; + +import gc.mda.kcg.auth.dto.AuthResponse; +import gc.mda.kcg.auth.dto.GoogleAuthRequest; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private static final String JWT_COOKIE_NAME = "kcg_token"; + + private final AuthService authService; + private final JwtProvider jwtProvider; + + /** + * Google OAuth2 id_token 검증 후 JWT 쿠키 발급 + */ + @PostMapping("/google") + public ResponseEntity googleLogin( + @Valid @RequestBody GoogleAuthRequest request, + HttpServletResponse response) { + + AuthResponse authResponse = authService.authenticateWithGoogle(request.getCredential()); + String token = jwtProvider.generateToken(authResponse.getEmail(), authResponse.getName()); + + Cookie cookie = new Cookie(JWT_COOKIE_NAME, token); + cookie.setHttpOnly(true); + cookie.setSecure(false); + cookie.setPath("/"); + cookie.setMaxAge((int) (jwtProvider.getExpirationMs() / 1000)); + response.addCookie(cookie); + + return ResponseEntity.ok(authResponse); + } + + /** + * JWT 쿠키에서 현재 사용자 정보 반환 + */ + @GetMapping("/me") + public ResponseEntity me( + @CookieValue(name = JWT_COOKIE_NAME, required = false) String token) { + + if (token == null || token.isBlank()) { + return ResponseEntity.status(401).build(); + } + + AuthResponse authResponse = authService.getUserFromToken(token); + return ResponseEntity.ok(authResponse); + } + + /** + * JWT 쿠키 삭제 (로그아웃) + */ + @PostMapping("/logout") + public ResponseEntity logout(HttpServletResponse response) { + Cookie cookie = new Cookie(JWT_COOKIE_NAME, ""); + cookie.setHttpOnly(true); + cookie.setSecure(false); + cookie.setPath("/"); + cookie.setMaxAge(0); + response.addCookie(cookie); + + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java new file mode 100644 index 0000000..d25beb4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -0,0 +1,70 @@ +package gc.mda.kcg.auth; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AuthFilter extends OncePerRequestFilter { + + private static final String JWT_COOKIE_NAME = "kcg_token"; + private static final String AUTH_PATH_PREFIX = "/api/auth/"; + + private final JwtProvider jwtProvider; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return path.startsWith(AUTH_PATH_PREFIX); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String token = extractTokenFromCookies(request); + + if (token == null || token.isBlank()) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("{\"error\":\"인증이 필요합니다.\"}"); + return; + } + + try { + Claims claims = jwtProvider.validateToken(token); + request.setAttribute("userEmail", claims.getSubject()); + request.setAttribute("userName", claims.get("name", String.class)); + filterChain.doFilter(request, response); + } catch (IllegalArgumentException e) { + log.warn("인증 실패: {}", e.getMessage()); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("{\"error\":\"유효하지 않은 토큰입니다.\"}"); + } + } + + private String extractTokenFromCookies(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + return Arrays.stream(cookies) + .filter(c -> JWT_COOKIE_NAME.equals(c.getName())) + .findFirst() + .map(Cookie::getValue) + .orElse(null); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthService.java b/backend/src/main/java/gc/mda/kcg/auth/AuthService.java new file mode 100644 index 0000000..876e272 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthService.java @@ -0,0 +1,130 @@ +package gc.mda.kcg.auth; + +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import gc.mda.kcg.auth.dto.AuthResponse; +import gc.mda.kcg.auth.entity.LoginHistory; +import gc.mda.kcg.auth.entity.User; +import gc.mda.kcg.auth.repository.LoginHistoryRepository; +import gc.mda.kcg.auth.repository.UserRepository; +import gc.mda.kcg.config.AppProperties; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Collections; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final AppProperties appProperties; + private final JwtProvider jwtProvider; + private final UserRepository userRepository; + private final LoginHistoryRepository loginHistoryRepository; + + /** + * Google id_token 검증 후 사용자 upsert 및 로그인 이력 기록 + */ + @Transactional + public AuthResponse authenticateWithGoogle(String credential) { + GoogleIdToken idToken = verifyGoogleToken(credential); + GoogleIdToken.Payload payload = idToken.getPayload(); + + String email = payload.getEmail(); + String name = (String) payload.get("name"); + String picture = (String) payload.get("picture"); + + validateEmailDomain(email); + + User user = upsertUser(email, name, picture); + recordLoginHistory(user); + + return AuthResponse.builder() + .email(user.getEmail()) + .name(user.getName()) + .picture(user.getPicture()) + .build(); + } + + /** + * JWT 토큰에서 사용자 정보 조회 + */ + @Transactional(readOnly = true) + public AuthResponse getUserFromToken(String token) { + Claims claims = jwtProvider.validateToken(token); + String email = claims.getSubject(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + email)); + + return AuthResponse.builder() + .email(user.getEmail()) + .name(user.getName()) + .picture(user.getPicture()) + .build(); + } + + private GoogleIdToken verifyGoogleToken(String credential) { + try { + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( + new NetHttpTransport(), GsonFactory.getDefaultInstance()) + .setAudience(Collections.singletonList(appProperties.getGoogle().getClientId())) + .build(); + + GoogleIdToken idToken = verifier.verify(credential); + if (idToken == null) { + throw new IllegalArgumentException("유효하지 않은 Google 토큰입니다."); + } + return idToken; + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + log.error("Google 토큰 검증 실패", e); + throw new IllegalArgumentException("Google 토큰 검증에 실패했습니다.", e); + } + } + + private void validateEmailDomain(String email) { + String allowedDomain = appProperties.getAuth().getAllowedDomain(); + if (allowedDomain != null && !allowedDomain.isBlank()) { + String domain = email.substring(email.indexOf('@') + 1); + if (!domain.equals(allowedDomain)) { + throw new IllegalArgumentException("허용되지 않은 이메일 도메인입니다: " + domain); + } + } + } + + private User upsertUser(String email, String name, String picture) { + return userRepository.findByEmail(email) + .map(existing -> { + existing.setName(name); + existing.setPicture(picture); + existing.setLastLoginAt(LocalDateTime.now()); + return userRepository.save(existing); + }) + .orElseGet(() -> { + User newUser = User.builder() + .email(email) + .name(name) + .picture(picture) + .lastLoginAt(LocalDateTime.now()) + .build(); + return userRepository.save(newUser); + }); + } + + private void recordLoginHistory(User user) { + LoginHistory history = LoginHistory.builder() + .user(user) + .loginAt(LocalDateTime.now()) + .build(); + loginHistoryRepository.save(history); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java b/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java new file mode 100644 index 0000000..3cd15a3 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java @@ -0,0 +1,68 @@ +package gc.mda.kcg.auth; + +import gc.mda.kcg.config.AppProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +public class JwtProvider { + + private final SecretKey secretKey; + @Getter + private final long expirationMs; + + public JwtProvider(AppProperties appProperties) { + this.secretKey = Keys.hmacShaKeyFor( + appProperties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8)); + this.expirationMs = appProperties.getJwt().getExpirationMs(); + } + + /** + * JWT 토큰 생성 + */ + public String generateToken(String email, String name) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + expirationMs); + + return Jwts.builder() + .subject(email) + .claim("name", name) + .issuedAt(now) + .expiration(expiry) + .signWith(secretKey) + .compact(); + } + + /** + * JWT 토큰 검증 및 Claims 반환 + */ + public Claims validateToken(String token) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException e) { + log.warn("JWT 토큰 검증 실패: {}", e.getMessage()); + throw new IllegalArgumentException("유효하지 않은 토큰입니다.", e); + } + } + + /** + * 토큰에서 이메일 추출 + */ + public String getEmailFromToken(String token) { + return validateToken(token).getSubject(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java b/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java new file mode 100644 index 0000000..e7a5901 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java @@ -0,0 +1,17 @@ +package gc.mda.kcg.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponse { + + private String email; + private String name; + private String picture; +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java b/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java new file mode 100644 index 0000000..6239c4b --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java @@ -0,0 +1,15 @@ +package gc.mda.kcg.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class GoogleAuthRequest { + + @NotBlank(message = "credential은 필수입니다.") + private String credential; +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java b/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java new file mode 100644 index 0000000..e994de7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java @@ -0,0 +1,38 @@ +package gc.mda.kcg.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "login_history", schema = "kcg") +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LoginHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "login_at", nullable = false) + @Builder.Default + private LocalDateTime loginAt = LocalDateTime.now(); +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/entity/User.java b/backend/src/main/java/gc/mda/kcg/auth/entity/User.java new file mode 100644 index 0000000..d939d15 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/entity/User.java @@ -0,0 +1,44 @@ +package gc.mda.kcg.auth.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "users", schema = "kcg") +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String name; + + private String picture; + + @Column(name = "last_login_at") + private LocalDateTime lastLoginAt; + + @Column(name = "created_at", nullable = false, updatable = false) + @Builder.Default + private LocalDateTime createdAt = LocalDateTime.now(); +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java b/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java new file mode 100644 index 0000000..b7c3556 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java @@ -0,0 +1,7 @@ +package gc.mda.kcg.auth.repository; + +import gc.mda.kcg.auth.entity.LoginHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LoginHistoryRepository extends JpaRepository { +} diff --git a/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java b/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java new file mode 100644 index 0000000..17d9ae5 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.auth.repository; + +import gc.mda.kcg.auth.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByEmail(String email); +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java b/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java new file mode 100644 index 0000000..83d6cd4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java @@ -0,0 +1,39 @@ +package gc.mda.kcg.collector; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 기사 분류기 (정규식 기반) + * TODO: 프론트엔드 classifyArticle() 로직 이식 + */ +@Component +public class ArticleClassifier { + + private static final Map CATEGORY_PATTERNS = Map.of( + "military", Pattern.compile("(?i)(strike|attack|military|weapon|missile|drone|bomb|combat|war|force)"), + "political", Pattern.compile("(?i)(sanction|diplomat|negotiat|treaty|nuclear|iran|deal|policy)"), + "intelligence", Pattern.compile("(?i)(intel|spy|surveillance|intercept|sigint|osint|recon)") + ); + + /** + * 기사 제목/본문을 분석하여 카테고리를 반환 + * + * @param title 기사 제목 + * @param content 기사 본문 + * @return 분류된 카테고리 (military, political, intelligence, general) + */ + public String classifyArticle(String title, String content) { + String text = (title + " " + content).toLowerCase(); + + for (Map.Entry entry : CATEGORY_PATTERNS.entrySet()) { + if (entry.getValue().matcher(text).find()) { + return entry.getKey(); + } + } + + return "general"; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java b/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java new file mode 100644 index 0000000..3708228 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.collector; + +import org.springframework.stereotype.Component; + +/** + * CENTCOM 보도자료 수집기 + * TODO: CENTCOM 웹사이트에서 보도자료 수집 구현 + */ +@Component +public class CentcomCollector { +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java b/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java new file mode 100644 index 0000000..9433654 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.collector; + +import org.springframework.stereotype.Component; + +/** + * GDELT 데이터 수집기 + * TODO: GDELT API를 통한 이벤트/뉴스 데이터 수집 구현 + */ +@Component +public class GdeltCollector { +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java b/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java new file mode 100644 index 0000000..3327623 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.collector; + +import org.springframework.stereotype.Component; + +/** + * Google News RSS 수집기 + * TODO: Google News RSS 피드를 통한 뉴스 데이터 수집 구현 + */ +@Component +public class GoogleNewsCollector { +} diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java new file mode 100644 index 0000000..686b67d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -0,0 +1,36 @@ +package gc.mda.kcg.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Setter +@Configuration +@ConfigurationProperties(prefix = "app") +public class AppProperties { + + private Jwt jwt = new Jwt(); + private Google google = new Google(); + private Auth auth = new Auth(); + + @Getter + @Setter + public static class Jwt { + private String secret; + private long expirationMs; + } + + @Getter + @Setter + public static class Google { + private String clientId; + } + + @Getter + @Setter + public static class Auth { + private String allowedDomain; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java b/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java new file mode 100644 index 0000000..986479e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java @@ -0,0 +1,19 @@ +package gc.mda.kcg.config; + +import gc.mda.kcg.auth.AuthFilter; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SecurityConfig { + + @Bean + public FilterRegistrationBean authFilterRegistration(AuthFilter authFilter) { + FilterRegistrationBean registration = new FilterRegistrationBean<>(); + registration.setFilter(authFilter); + registration.addUrlPatterns("/api/*"); + registration.setOrder(1); + return registration; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/WebConfig.java b/backend/src/main/java/gc/mda/kcg/config/WebConfig.java new file mode 100644 index 0000000..f18492d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/WebConfig.java @@ -0,0 +1,18 @@ +package gc.mda.kcg.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("http://localhost:5173") + .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java new file mode 100644 index 0000000..f62a8d0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java @@ -0,0 +1,4 @@ +/** + * 항공기 도메인 (향후 구현) + */ +package gc.mda.kcg.domain.aircraft; diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java new file mode 100644 index 0000000..faa6bf5 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java @@ -0,0 +1,4 @@ +/** + * 이벤트 도메인 (향후 구현) + */ +package gc.mda.kcg.domain.event; diff --git a/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java new file mode 100644 index 0000000..16142ce --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java @@ -0,0 +1,4 @@ +/** + * 뉴스 도메인 (향후 구현) + */ +package gc.mda.kcg.domain.news; diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java new file mode 100644 index 0000000..0a78dff --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java @@ -0,0 +1,4 @@ +/** + * OSINT 도메인 (향후 구현) + */ +package gc.mda.kcg.domain.osint; diff --git a/backend/src/main/resources/application-local.yml.example b/backend/src/main/resources/application-local.yml.example new file mode 100644 index 0000000..0fa5862 --- /dev/null +++ b/backend/src/main/resources/application-local.yml.example @@ -0,0 +1,13 @@ +spring: + datasource: + url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg + username: kcg_user + password: kcg_pass +app: + jwt: + secret: local-dev-secret-key-32chars-minimum!! + expiration-ms: 86400000 + google: + client-id: YOUR_GOOGLE_CLIENT_ID + auth: + allowed-domain: gcsc.co.kr diff --git a/backend/src/main/resources/application-prod.yml.example b/backend/src/main/resources/application-prod.yml.example new file mode 100644 index 0000000..71c2848 --- /dev/null +++ b/backend/src/main/resources/application-prod.yml.example @@ -0,0 +1,13 @@ +spring: + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} +app: + jwt: + secret: ${JWT_SECRET} + expiration-ms: ${JWT_EXPIRATION_MS:86400000} + google: + client-id: ${GOOGLE_CLIENT_ID} + auth: + allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..19fc170 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,11 @@ +spring: + profiles: + active: ${SPRING_PROFILES_ACTIVE:local} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + default_schema: kcg +server: + port: 8080 diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..f9b5509 --- /dev/null +++ b/database/init.sql @@ -0,0 +1,2 @@ +-- KCG 데이터베이스 초기화 +CREATE SCHEMA IF NOT EXISTS kcg; diff --git a/database/migration/001_initial_schema.sql b/database/migration/001_initial_schema.sql new file mode 100644 index 0000000..3b9eadc --- /dev/null +++ b/database/migration/001_initial_schema.sql @@ -0,0 +1,83 @@ +-- 001: 초기 스키마 생성 +-- events, news, osint, users, login_history + +SET search_path TO kcg; + +-- 이벤트 테이블 (GDELT 등 이벤트 데이터) +CREATE TABLE IF NOT EXISTS events ( + id BIGSERIAL PRIMARY KEY, + event_id VARCHAR(64) UNIQUE, + title TEXT NOT NULL, + description TEXT, + source VARCHAR(128), + source_url TEXT, + category VARCHAR(64), + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + timestamp TIMESTAMP NOT NULL, + raw_data JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events (timestamp); + +-- 뉴스 테이블 +CREATE TABLE IF NOT EXISTS news ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT, + source VARCHAR(128), + source_url TEXT UNIQUE, + category VARCHAR(64), + language VARCHAR(8), + timestamp TIMESTAMP NOT NULL, + raw_data JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_news_timestamp ON news (timestamp); + +-- OSINT 테이블 +CREATE TABLE IF NOT EXISTS osint ( + id BIGSERIAL PRIMARY KEY, + title TEXT NOT NULL, + content TEXT, + source VARCHAR(128), + source_url TEXT UNIQUE, + category VARCHAR(64), + credibility SMALLINT, + latitude DOUBLE PRECISION, + longitude DOUBLE PRECISION, + timestamp TIMESTAMP NOT NULL, + raw_data JSONB, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_osint_timestamp ON osint (timestamp); +CREATE INDEX IF NOT EXISTS idx_osint_category ON osint (category); + +-- 사용자 테이블 +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + name VARCHAR(128) NOT NULL, + picture TEXT, + last_login_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_users_email ON users (email); + +-- 로그인 이력 테이블 +CREATE TABLE IF NOT EXISTS login_history ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + login_at TIMESTAMP NOT NULL DEFAULT NOW(), + ip_address VARCHAR(45), + user_agent TEXT, + success BOOLEAN NOT NULL DEFAULT TRUE, + failure_reason VARCHAR(100) +); + +CREATE INDEX IF NOT EXISTS idx_login_history_user_id ON login_history (user_id); +CREATE INDEX IF NOT EXISTS idx_login_history_login_at ON login_history (login_at); diff --git a/prediction/README.md b/prediction/README.md new file mode 100644 index 0000000..7f0ff41 --- /dev/null +++ b/prediction/README.md @@ -0,0 +1,17 @@ +# KCG Prediction Service + +FastAPI 기반 예측 서비스 (향후 구현 예정) + +## 실행 + +```bash +cd prediction +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8000 +``` + +## 엔드포인트 + +- `GET /health` - 헬스 체크 diff --git a/prediction/main.py b/prediction/main.py new file mode 100644 index 0000000..fbccfe3 --- /dev/null +++ b/prediction/main.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI(title="KCG Prediction Service", version="0.1.0") + + +@app.get("/health") +def health_check(): + return {"status": "ok"} diff --git a/prediction/requirements.txt b/prediction/requirements.txt new file mode 100644 index 0000000..531efda --- /dev/null +++ b/prediction/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.115.0 +uvicorn==0.30.6