mirror of
https://github.com/headporter81/specialsource-homepage-backend.git
synced 2026-10-07 15:21:09 +09:00
Merge remote-tracking branch 'origin/master'
SpecialSource Backend CI/CD / build-and-deploy (push) Successful in 1m25s
SpecialSource Backend CI/CD / build-and-deploy (push) Successful in 1m25s
# Conflicts: # src/main/java/company/specialsource/SpecialsourceHomepageApplication.java
This commit is contained in:
@@ -32,9 +32,12 @@ jobs:
|
|||||||
docker stop ss-backend-container || true
|
docker stop ss-backend-container || true
|
||||||
docker rm ss-backend-container || true
|
docker rm ss-backend-container || true
|
||||||
|
|
||||||
# 3) 비어있는 18080 포트와 지정하신 app 폴더 경로를 연결하여 실행
|
# 3) [★확정] 진짜 NAS 사설 IP 주소와 DB_URL 변수명을 매핑하여 실행
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name ss-backend-container \
|
--name ss-backend-container \
|
||||||
-p 18080:8080 \
|
-p 18080:8080 \
|
||||||
|
-e JASYPT_ENCRYPTOR_PASSWORD=81sungjin1015 \
|
||||||
|
-e DB_URL=jdbc:postgresql://192.168.0.215:5435/mydb?sslmode=disable \
|
||||||
-v /volume1/docker/app/specialsource-backend/logs:/app/logs \
|
-v /volume1/docker/app/specialsource-backend/logs:/app/logs \
|
||||||
|
--restart always \
|
||||||
ss-backend-image
|
ss-backend-image
|
||||||
+2
-3
@@ -1,7 +1,6 @@
|
|||||||
|
#Thu May 28 11:00:03 KST 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip
|
||||||
networkTimeout=10000
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
|||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@EnableScheduling // 스케줄러가 작동 선언
|
||||||
public class SpecialsourceHomepageApplication {
|
public class SpecialsourceHomepageApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
package company.specialsource.config;
|
|
||||||
|
|
||||||
import com.ulisesbocchio.jasyptspringboot.annotation.EnableEncryptableProperties;
|
|
||||||
import org.jasypt.encryption.StringEncryptor;
|
|
||||||
import org.jasypt.encryption.pbe.PooledPBEStringEncryptor;
|
|
||||||
import org.jasypt.encryption.pbe.config.SimpleStringPBEConfig;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
|
|
||||||
@Configuration
|
|
||||||
@EnableEncryptableProperties
|
|
||||||
public class JasyptConfig {
|
|
||||||
|
|
||||||
@Value("${jasypt.encryptor.password}")
|
|
||||||
private String password;
|
|
||||||
|
|
||||||
@Bean("jasyptStringEncryptor")
|
|
||||||
public StringEncryptor stringEncryptor() {
|
|
||||||
PooledPBEStringEncryptor encryptor = new PooledPBEStringEncryptor();
|
|
||||||
SimpleStringPBEConfig config = new SimpleStringPBEConfig();
|
|
||||||
config.setPassword(password); // 암호화 키 (절대 외부에 유출 금지!)
|
|
||||||
config.setAlgorithm("PBEWithMD5AndDES");
|
|
||||||
config.setKeyObtentionIterations("1000");
|
|
||||||
config.setPoolSize("1");
|
|
||||||
config.setProviderName("SunJCE");
|
|
||||||
config.setSaltGeneratorClassName("org.jasypt.salt.RandomSaltGenerator");
|
|
||||||
config.setIvGeneratorClassName("org.jasypt.iv.NoIvGenerator");
|
|
||||||
config.setStringOutputType("base64");
|
|
||||||
encryptor.setConfig(config);
|
|
||||||
return encryptor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,14 +24,14 @@ public class SecurityConfig {
|
|||||||
.csrf(csrf -> csrf.disable()) // REST API 통신을 위해 CSRF 비활성화
|
.csrf(csrf -> csrf.disable()) // REST API 통신을 위해 CSRF 비활성화
|
||||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
.requestMatchers("/api/login").permitAll() // 로그인 API는 누구나 접근 가능
|
// 로그인 API 및 파일 동기화 업로드 API는 인증 없이 누구나 접근 가능하도록 허용
|
||||||
|
.requestMatchers("/api/login", "/api/sync/upload").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
return http.build();
|
return http.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder(); // BCrypt 암호화 알고리즘 사용
|
return new BCryptPasswordEncoder(); // BCrypt 암호화 알고리즘 사용
|
||||||
|
|||||||
@@ -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,62 @@
|
|||||||
|
package company.specialsource.controller;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
public class FileSyncController {
|
||||||
|
|
||||||
|
@PostMapping("/api/sync/upload")
|
||||||
|
public ResponseEntity<?> uploadFile(
|
||||||
|
HttpServletRequest request,
|
||||||
|
@RequestParam(value = "filename", required = false) String originalFileName) {
|
||||||
|
|
||||||
|
// 🟢 디버깅용 로그: MacroDroid가 주소창에 무엇을 실어서 보냈는지 포착합니다.
|
||||||
|
System.out.println("🔗 전체 쿼리 스트링: " + request.getQueryString());
|
||||||
|
System.out.println("📂 수신된 파일명 변수값: " + originalFileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String fileName = originalFileName;
|
||||||
|
|
||||||
|
// 🟢 파일명이 누락되었을 때 400 에러로 터뜨리지 않고, 무엇이 잘못되었는지 로그를 남긴 후 임시 저장합니다.
|
||||||
|
if (fileName == null || fileName.trim().isEmpty()) {
|
||||||
|
System.out.println("⚠️ 경고: MacroDroid에서 filename 파라미터가 빈 값으로 전송되었습니다.");
|
||||||
|
fileName = "missing_name_" + System.currentTimeMillis() + ".dat";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 나스 실제 절대 경로 설정
|
||||||
|
String uploadDirPath = "/volume1/docker/upload_dir/";
|
||||||
|
File uploadDir = new File(uploadDirPath);
|
||||||
|
if (!uploadDir.exists()) {
|
||||||
|
uploadDir.mkdirs();
|
||||||
|
}
|
||||||
|
|
||||||
|
File targetFile = new File(uploadDir, fileName);
|
||||||
|
|
||||||
|
// 파일 저장 진행
|
||||||
|
try (InputStream is = request.getInputStream();
|
||||||
|
FileOutputStream fos = new FileOutputStream(targetFile)) {
|
||||||
|
|
||||||
|
byte[] buffer = new byte[4096];
|
||||||
|
int bytesRead;
|
||||||
|
while ((bytesRead = is.read(buffer)) != -1) {
|
||||||
|
fos.write(buffer, 0, bytesRead);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println("✅ 파일 처리 완료: " + targetFile.getAbsolutePath());
|
||||||
|
return ResponseEntity.ok("Upload Processed: " + fileName);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return ResponseEntity.internalServerError().body("Upload Error: " + 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,11 +1,17 @@
|
|||||||
spring:
|
spring:
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
enabled: true
|
||||||
|
max-file-size: 5GB # 🟢 개별 파일 허용 용량을 5기가로 대폭 확장
|
||||||
|
max-request-size: 5GB # 🟢 전체 HTTP 요청 허용 용량도 5기가로 맞춤
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:postgresql://192.168.0.215:5435/mydb
|
url: ${DB_URL:jdbc:postgresql://localhost:5435/mydb?sslmode=disable}
|
||||||
username: headporter
|
username: headporter
|
||||||
password: ENC(Gg03NzjjcVOrFSXoBrPEusNgDNbYOPO4)
|
password: ENC(Gg03NzjjcVOrFSXoBrPEusNgDNbYOPO4)
|
||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
|
database-platform: org.hibernate.dialect.PostgreSQLDialect
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update # 테이블 자동 생성
|
ddl-auto: update # 테이블 자동 생성
|
||||||
show-sql: true # SQL 로그 출력
|
show-sql: true # SQL 로그 출력
|
||||||
@@ -13,9 +19,18 @@ spring:
|
|||||||
hibernate:
|
hibernate:
|
||||||
format_sql: true
|
format_sql: true
|
||||||
|
|
||||||
|
server:
|
||||||
|
tomcat:
|
||||||
|
max-swallow-size: 5GB # 🟢 톰캣 서버가 업로드 중에 차단하지 않도록 크기 동기화 (또는 -1로 설정 시 무제한)
|
||||||
|
connection-timeout: 3600000 # 🟢 대용량 전송 시간을 고려하여 타임아웃을 1시간(3,600,000 밀리초)으로 넉넉히 연장
|
||||||
|
|
||||||
jasypt:
|
jasypt:
|
||||||
encryptor:
|
encryptor:
|
||||||
password: 81sungjin1015
|
password: 81sungjin1015
|
||||||
|
algorithm: PBEWithMD5AndDES
|
||||||
|
iv-generator-classname: org.jasypt.iv.NoIvGenerator
|
||||||
|
key-obtention-iterations: 1000
|
||||||
|
pool-size: 1
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
# 256비트 이상이어야 하므로 영문+숫자 (최소 32글자 이상)
|
# 256비트 이상이어야 하므로 영문+숫자 (최소 32글자 이상)
|
||||||
|
|||||||
Reference in New Issue
Block a user