feat: 백엔드 Spring Boot + DB 스키마 + Python 분석서버 스켈레톤
- 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) <noreply@anthropic.com>
This commit is contained in:
부모
2534faa488
커밋
512020d6bb
1
backend/.sdkmanrc
Normal file
1
backend/.sdkmanrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
java=17.0.18-amzn
|
||||||
106
backend/pom.xml
Normal file
106
backend/pom.xml
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.5</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>gc.mda</groupId>
|
||||||
|
<artifactId>kcg</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
<name>kcg</name>
|
||||||
|
<description>KCG Monitoring Dashboard Backend</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<jjwt.version>0.12.6</jjwt.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Starters -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Database -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-api</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-impl</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
<artifactId>jjwt-jackson</artifactId>
|
||||||
|
<version>${jjwt.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Google API Client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.api-client</groupId>
|
||||||
|
<artifactId>google-api-client</artifactId>
|
||||||
|
<version>2.7.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>kcg</finalName>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
14
backend/src/main/java/gc/mda/kcg/KcgApplication.java
Normal file
14
backend/src/main/java/gc/mda/kcg/KcgApplication.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
77
backend/src/main/java/gc/mda/kcg/auth/AuthController.java
Normal file
77
backend/src/main/java/gc/mda/kcg/auth/AuthController.java
Normal file
@ -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<AuthResponse> 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<AuthResponse> 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<Void> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java
Normal file
70
backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
130
backend/src/main/java/gc/mda/kcg/auth/AuthService.java
Normal file
130
backend/src/main/java/gc/mda/kcg/auth/AuthService.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java
Normal file
68
backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java
Normal file
17
backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
44
backend/src/main/java/gc/mda/kcg/auth/entity/User.java
Normal file
44
backend/src/main/java/gc/mda/kcg/auth/entity/User.java
Normal file
@ -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();
|
||||||
|
}
|
||||||
@ -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<LoginHistory, Long> {
|
||||||
|
}
|
||||||
@ -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<User, Long> {
|
||||||
|
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
}
|
||||||
@ -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<String, Pattern> 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<String, Pattern> entry : CATEGORY_PATTERNS.entrySet()) {
|
||||||
|
if (entry.getValue().matcher(text).find()) {
|
||||||
|
return entry.getKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "general";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package gc.mda.kcg.collector;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CENTCOM 보도자료 수집기
|
||||||
|
* TODO: CENTCOM 웹사이트에서 보도자료 수집 구현
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class CentcomCollector {
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package gc.mda.kcg.collector;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GDELT 데이터 수집기
|
||||||
|
* TODO: GDELT API를 통한 이벤트/뉴스 데이터 수집 구현
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class GdeltCollector {
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
}
|
||||||
36
backend/src/main/java/gc/mda/kcg/config/AppProperties.java
Normal file
36
backend/src/main/java/gc/mda/kcg/config/AppProperties.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java
Normal file
19
backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java
Normal file
@ -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<AuthFilter> authFilterRegistration(AuthFilter authFilter) {
|
||||||
|
FilterRegistrationBean<AuthFilter> registration = new FilterRegistrationBean<>();
|
||||||
|
registration.setFilter(authFilter);
|
||||||
|
registration.addUrlPatterns("/api/*");
|
||||||
|
registration.setOrder(1);
|
||||||
|
return registration;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
backend/src/main/java/gc/mda/kcg/config/WebConfig.java
Normal file
18
backend/src/main/java/gc/mda/kcg/config/WebConfig.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* 항공기 도메인 (향후 구현)
|
||||||
|
*/
|
||||||
|
package gc.mda.kcg.domain.aircraft;
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* 이벤트 도메인 (향후 구현)
|
||||||
|
*/
|
||||||
|
package gc.mda.kcg.domain.event;
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* 뉴스 도메인 (향후 구현)
|
||||||
|
*/
|
||||||
|
package gc.mda.kcg.domain.news;
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* OSINT 도메인 (향후 구현)
|
||||||
|
*/
|
||||||
|
package gc.mda.kcg.domain.osint;
|
||||||
13
backend/src/main/resources/application-local.yml.example
Normal file
13
backend/src/main/resources/application-local.yml.example
Normal file
@ -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
|
||||||
13
backend/src/main/resources/application-prod.yml.example
Normal file
13
backend/src/main/resources/application-prod.yml.example
Normal file
@ -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}
|
||||||
11
backend/src/main/resources/application.yml
Normal file
11
backend/src/main/resources/application.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
spring:
|
||||||
|
profiles:
|
||||||
|
active: ${SPRING_PROFILES_ACTIVE:local}
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
default_schema: kcg
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
2
database/init.sql
Normal file
2
database/init.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- KCG 데이터베이스 초기화
|
||||||
|
CREATE SCHEMA IF NOT EXISTS kcg;
|
||||||
83
database/migration/001_initial_schema.sql
Normal file
83
database/migration/001_initial_schema.sql
Normal file
@ -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);
|
||||||
17
prediction/README.md
Normal file
17
prediction/README.md
Normal file
@ -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` - 헬스 체크
|
||||||
8
prediction/main.py
Normal file
8
prediction/main.py
Normal file
@ -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"}
|
||||||
2
prediction/requirements.txt
Normal file
2
prediction/requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
불러오는 중...
Reference in New Issue
Block a user