feat - 전반적인 기능 추가
SpecialSource Backend CI/CD / build-and-deploy (push) Successful in 2m14s

This commit is contained in:
sungjin.choi
2026-07-16 10:20:45 +09:00
parent d23d649b2b
commit 4a97610eaf
12 changed files with 393 additions and 0 deletions
@@ -2,8 +2,10 @@ package company.specialsource;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling // 스케줄러가 작동 선언
public class SpecialsourceHomepageApplication {
public static void main(String[] args) {
@@ -0,0 +1,79 @@
package company.specialsource.config;
import company.specialsource.entity.VisitorLog;
import company.specialsource.repository.VisitorLogRepository;
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.servlet.HandlerInterceptor;
import java.time.ZonedDateTime;
@Component
@RequiredArgsConstructor
@Slf4j
public class VisitorInterceptor implements HandlerInterceptor {
private final VisitorLogRepository visitorLogRepository;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 1. 진짜 클라이언트 IP 추출
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
// 2. 🟢 [NEW] 현재 요청을 보낸 유저의 식별 정보 추적 (기본값: anonymous)
String username = "anonymous";
// 방법 A: Spring Security인증 인프라를 통과한 상태인 경우
if (request.getUserPrincipal() != null) {
username = request.getUserPrincipal().getName();
}
// 방법 B: 만약 JWT 토큰을 Header로 직접 파싱하고 계시다면 아래 주석을 활용하세요.
/*
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
username = jwtProvider.getUsername(token); // 프로젝트의 JWT 컴포넌트 메서드 호출
}
*/
try {
// 중복 카운팅 방지: 동일 IP가 최근 1시간 이내에 접속한 로그가 있는지 확인
ZonedDateTime oneHourAgo = ZonedDateTime.now().minusHours(1);
boolean isRecentVisitor = visitorLogRepository.existsByIpAddressAndAccessedAtAfter(ip, oneHourAgo);
// 1시간 이내에 왔던 기록이 없거나, 'anonymous'가 아닌 실제 '회원 로그인 ID'인 경우는 무조건 기록 갱신
if (!isRecentVisitor || !"anonymous".equals(username)) {
VisitorLog visitorLog = new VisitorLog();
visitorLog.setIpAddress(ip);
visitorLog.setUsername(username); // 🟢 추출한 아이디 또는 anonymous 주입
String userAgent = request.getHeader("User-Agent");
if (userAgent != null && userAgent.length() > 500) {
userAgent = userAgent.substring(0, 500);
}
visitorLog.setUserAgent(userAgent);
visitorLog.setSessionId(request.getSession().getId());
visitorLogRepository.save(visitorLog);
log.info("📊 [TRAFFIC LOG] 관제 시스템 기록 완료 -> 유저: [{}], IP: {}", username, ip);
}
} catch (Exception e) {
log.error("❌ [TRAFFIC ERROR] 방문자 식별 로그 적재 중 예외 발생: ", e);
}
return true;
}
}
@@ -0,0 +1,20 @@
package company.specialsource.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebMvcConfig implements WebMvcConfigurer {
private final VisitorInterceptor visitorInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(visitorInterceptor)
.addPathPatterns("/api/**") // 🟢 모든 API 주소로 들어오는 길목을 차단하여 감시
.excludePathPatterns("/api/analytics/**", "/static/**", "/favicon.ico"); // 통계 조회 API 자체는 중복 카운팅 차단
}
}
@@ -0,0 +1,27 @@
package company.specialsource.controller;
import company.specialsource.service.AnalyticsService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/analytics")
@RequiredArgsConstructor
public class AnalyticsController {
private final AnalyticsService analyticsService;
/**
* Vue 3 프론트엔드가 대시보드 진입 시 호출할 실시간 유저 수 조회용 엔드포인트
*/
@GetMapping("/live")
public ResponseEntity<Map<String, Long>> getLiveNetworkStats() {
Map<String, Long> liveStats = analyticsService.getRealTimeDashboardStats();
return ResponseEntity.ok(liveStats);
}
}
@@ -0,0 +1,52 @@
package company.specialsource.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
@RestController
@RequestMapping("/api/sync")
@Slf4j
public class FileSyncController {
// ⚠️ 나스 스토리지와 볼륨 마운트해 둔 백엔드 컨테이너 내부의 실제 저장 경로
private final String NAS_STORAGE_PATH = "/app/storage/sync-folder/";
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<String> uploadFileFromPhone(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
log.warn("⚠️ [Sync] 수신된 파일이 비어 있습니다.");
return ResponseEntity.badRequest().body("ERR: EMPTY_FILE");
}
try {
// 저장 경로 폴더가 없으면 자동으로 생성
File directory = new File(NAS_STORAGE_PATH);
if (!directory.exists()) {
directory.mkdirs();
log.info("📁 [Sync] 업로드 저장 폴더를 새로 생성했습니다: {}", NAS_STORAGE_PATH);
}
// 핸드폰에서 온 원본 파일명 그대로 파일 객체 생성
File destination = new File(directory, file.getOriginalFilename());
// 물리 디스크(나스 HDD/SSD)에 실제 파일 쓰기 수행
file.transferTo(destination);
log.info("📱 [Sync] 스마트폰 동기화 완료! 파일명: {} (크기: {} bytes)",
file.getOriginalFilename(), file.getSize());
return ResponseEntity.ok("SUCCESS: INJECTED_TO_NAS");
} catch (IOException e) {
log.error("❌ [Sync] 나스 파일 저장 중 치명적 오류 발생", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("ERR: " + e.getMessage());
}
}
}
@@ -0,0 +1,21 @@
package company.specialsource.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDate;
@Entity
@Table(name = "daily_visitor_stats")
@Getter @Setter
public class DailyVisitorStats {
@Id
@Column(name = "visit_date")
private LocalDate visitDate; // 정산 기준 날짜 (PK)
@Column(name = "unique_visitor_count")
private Integer uniqueVisitorCount = 0;
@Column(name = "page_view_count")
private Integer pageViewCount = 0;
}
@@ -0,0 +1,31 @@
package company.specialsource.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import java.time.ZonedDateTime;
@Entity
@Table(name = "visitor_log")
@Getter @Setter
public class VisitorLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 🟢 [추가] 접속자 아이디 또는 anonymous 저장용 컬럼
@Column(name = "username", length = 50, nullable = false)
private String username = "anonymous";
@Column(name = "ip_address", nullable = false)
private String ipAddress;
@Column(name = "user_agent", length = 500)
private String userAgent;
@Column(name = "session_id")
private String sessionId;
@Column(name = "accessed_at")
private ZonedDateTime accessedAt = ZonedDateTime.now();
}
@@ -0,0 +1,21 @@
package company.specialsource.repository;
import company.specialsource.entity.DailyVisitorStats;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
@Repository
public interface DailyVisitorStatsRepository extends JpaRepository<DailyVisitorStats, LocalDate> {
// 💡 일일 통계 정산 데이터는 JPA가 기본 제공하는 save() 메서드만으로도
// 새벽마다 적재(Insert/Update)하는 비즈니스 로직을 에러 없이 완벽하게 수행할 수 있습니다.
/* 나중에 레트로 대시보드 기능을 확장하여
"최근 일주일간 일별 방문자 추이 그래프" 같은 통계를 프론트엔드에 뿌리고 싶다면,
아래와 같은 쿼리 메서드를 여기에 추가해서 사용하시면 아주 유용합니다.
List<DailyVisitorStats> findByVisitDateBetweenOrderByVisitDateAsc(LocalDate start, LocalDate end);
*/
}
@@ -0,0 +1,40 @@
package company.specialsource.repository;
import company.specialsource.entity.VisitorLog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.ZonedDateTime;
@Repository
public interface VisitorLogRepository extends JpaRepository<VisitorLog, Long> {
// 1시간 중복 방문 여부 확인 메서드
boolean existsByIpAddressAndAccessedAtAfter(String ipAddress, ZonedDateTime time);
// 오늘 기준 실시간 고유 접속자 카운트 (중복 IP 제거)
@Query("SELECT COUNT(DISTINCT v.ipAddress) FROM VisitorLog v WHERE v.accessedAt >= :startOfDay")
long countUniqueVisitorsToday(@Param("startOfDay") ZonedDateTime startOfDay);
// 실시간 활동 유저 카운트 (최근 5분 이내 활동한 고유 IP 수)
@Query("SELECT COUNT(DISTINCT v.ipAddress) FROM VisitorLog v WHERE v.accessedAt >= :fiveMinutesAgo")
long countActiveUsers(@Param("fiveMinutesAgo") ZonedDateTime fiveMinutesAgo);
// 정산 배치용: 특정 날짜 범위 고유 방문자수 집계
@Query("SELECT COUNT(DISTINCT v.ipAddress) FROM VisitorLog v WHERE v.accessedAt >= :start AND v.accessedAt < :end")
long countUniqueVisitorsBetween(@Param("start") ZonedDateTime start, @Param("end") ZonedDateTime end);
// 정산 배치용: 특정 날짜 범위 총 페이지 뷰 집계
@Query("SELECT COUNT(v) FROM VisitorLog v WHERE v.accessedAt >= :start AND v.accessedAt < :end")
long countTotalPageViewsBetween(@Param("start") ZonedDateTime start, @Param("end") ZonedDateTime end);
// 데이터 클리닝: 30일 지난 상세 로그 삭제 (벌크 삭제 연산 트랜지션 처리 보장)
@Modifying
@Transactional
@Query("DELETE FROM VisitorLog v WHERE v.accessedAt < :threshold")
void deleteOldLogsBefore(@Param("threshold") ZonedDateTime threshold);
}
@@ -0,0 +1,32 @@
package company.specialsource.scheduler;
import company.specialsource.service.AnalyticsService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component
@RequiredArgsConstructor
@Slf4j
public class VisitorScheduler {
private final AnalyticsService analyticsService;
/**
* 매일 새벽 0시 5분에 정확히 트리거되는 크론 배치 스케줄링 메서드
*/
@Scheduled(cron = "0 5 0 * * *")
public void runDailySettlementTask() {
// 정산 기준은 당연히 어제(하루 전) 날짜임
LocalDate yesterday = LocalDate.now().minusDays(1);
try {
analyticsService.processDailySettlement(yesterday);
log.info("🚀 [AUTO SCHEDULER] 일일 통계 무인 정산 작업이 성공적으로 종료되었습니다.");
} catch (Exception e) {
log.error("🚨 [CRITICAL SCHEDULER ERROR] 자동 정산 도중 치명적인 시스템 오류 발생: ", e);
}
}
}
@@ -0,0 +1,67 @@
package company.specialsource.service;
import company.specialsource.entity.DailyVisitorStats;
import company.specialsource.repository.DailyVisitorStatsRepository;
import company.specialsource.repository.VisitorLogRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
@Slf4j
public class AnalyticsService {
private final VisitorLogRepository visitorLogRepository;
private final DailyVisitorStatsRepository dailyVisitorStatsRepository;
/**
* 관제 대시보드용 실시간 통계 카운트 맵 산출
*/
@Transactional(readOnly = true)
public Map<String, Long> getRealTimeDashboardStats() {
ZonedDateTime startOfToday = LocalDate.now().atStartOfDay(ZoneId.systemDefault());
ZonedDateTime fiveMinutesAgo = ZonedDateTime.now().minusMinutes(5);
Map<String, Long> statsMap = new HashMap<>();
statsMap.put("todayUnique", visitorLogRepository.countUniqueVisitorsToday(startOfToday));
statsMap.put("activeNow", visitorLogRepository.countActiveUsers(fiveMinutesAgo));
return statsMap;
}
/**
* 스케줄러 호출용 일일 결산 정산 실행 처리 비즈니스 로직
*/
@Transactional
public void processDailySettlement(LocalDate targetDate) {
// 어제 시작 시간(00:00:00) ~ 어제 끝 시간(23:59:59) 확보
ZonedDateTime startTimeline = targetDate.atStartOfDay(ZoneId.systemDefault());
ZonedDateTime endTimeline = startTimeline.plusDays(1);
// 연산 쿼리 수행
long uniqueVisitors = visitorLogRepository.countUniqueVisitorsBetween(startTimeline, endTimeline);
long totalPageViews = visitorLogRepository.countTotalPageViewsBetween(startTimeline, endTimeline);
// 캐시 데이터베이스 빌드 및 세이브
DailyVisitorStats dailyStats = new DailyVisitorStats();
dailyStats.setVisitDate(targetDate);
dailyStats.setUniqueVisitorCount((int) uniqueVisitors);
dailyStats.setPageViewCount((int) totalPageViews);
dailyVisitorStatsRepository.save(dailyStats);
log.info("📊 [ANALYTICS ENGINE] 날짜 [{}] 정산 스냅샷 세이브 완료 -> UV: {}, PV: {}", targetDate, uniqueVisitors, totalPageViews);
// 30일이 지나 쓸모없어진 원본 쓰레기 데이터 폐기 청소기 가동
ZonedDateTime expirationThreshold = ZonedDateTime.now().minusDays(30);
visitorLogRepository.deleteOldLogsBefore(expirationThreshold);
log.info("🗑️ [ANALYTICS ENGINE] 디비 하드디스크 무한 용량 방어를 위해 30일 경과 로그 원격 청소 완료.");
}
}
+1
View File
@@ -6,6 +6,7 @@ spring:
driver-class-name: org.postgresql.Driver
jpa:
database-platform: org.hibernate.dialect.PostgreSQLDialect
hibernate:
ddl-auto: update # 테이블 자동 생성
show-sql: true # SQL 로그 출력