From 1d42aacd6eb26495cf98fc45acdb0efeb715e031 Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Thu, 28 May 2026 14:32:11 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat=20-=20=EC=A0=84=EB=B0=98=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 5 ++- gradle/wrapper/gradle-wrapper.properties | 5 ++- .../specialsource/config/JasyptConfig.java | 33 ------------------- src/main/resources/application.yaml | 6 +++- 4 files changed, 11 insertions(+), 38 deletions(-) delete mode 100644 src/main/java/company/specialsource/config/JasyptConfig.java diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index b5afe76..5ce2c74 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -32,9 +32,12 @@ jobs: docker stop ss-backend-container || true docker rm ss-backend-container || true - # 3) 비어있는 18080 포트와 지정하신 app 폴더 경로를 연결하여 실행 + # 3) [★핵심 변경] 환경 변수, 볼륨 마운트, 자동 재시작 옵션을 적용하여 컨테이너 실행 docker run -d \ --name ss-backend-container \ -p 18080:8080 \ + -e JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} \ + -e DB_URL=jdbc:postgresql://172.17.0.1:5435/mydb?sslmode=disable \ -v /volume1/docker/app/specialsource-backend/logs:/app/logs \ + --restart always \ ss-backend-image \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 94113f2..d5e86cd 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ +#Thu May 28 11:00:03 KST 2026 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip -networkTimeout=10000 -validateDistributionUrl=true +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/company/specialsource/config/JasyptConfig.java b/src/main/java/company/specialsource/config/JasyptConfig.java deleted file mode 100644 index 5551c76..0000000 --- a/src/main/java/company/specialsource/config/JasyptConfig.java +++ /dev/null @@ -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; - } -} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 9640b51..92a7ff2 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:postgresql://192.168.0.215:5435/mydb + url: jdbc:postgresql://localhost:5435/mydb username: headporter password: ENC(Gg03NzjjcVOrFSXoBrPEusNgDNbYOPO4) driver-class-name: org.postgresql.Driver @@ -16,6 +16,10 @@ spring: jasypt: encryptor: password: 81sungjin1015 + algorithm: PBEWithMD5AndDES + iv-generator-classname: org.jasypt.iv.NoIvGenerator + key-obtention-iterations: 1000 + pool-size: 1 jwt: # 256비트 이상이어야 하므로 영문+숫자 (최소 32글자 이상) From d23d649b2b918ca2c5773331155b0e9029f1aeb5 Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Thu, 28 May 2026 14:38:52 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat=20-=20=EC=A0=84=EB=B0=98=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 6 +++--- src/main/resources/application.yaml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 5ce2c74..5c10ecf 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -32,12 +32,12 @@ jobs: docker stop ss-backend-container || true docker rm ss-backend-container || true - # 3) [★핵심 변경] 환경 변수, 볼륨 마운트, 자동 재시작 옵션을 적용하여 컨테이너 실행 + # 3) [★확정] 진짜 NAS 사설 IP 주소와 DB_URL 변수명을 매핑하여 실행 docker run -d \ --name ss-backend-container \ -p 18080:8080 \ - -e JASYPT_ENCRYPTOR_PASSWORD=${{ secrets.JASYPT_PASSWORD }} \ - -e DB_URL=jdbc:postgresql://172.17.0.1:5435/mydb?sslmode=disable \ + -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 \ --restart always \ ss-backend-image \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 92a7ff2..15c5fc3 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,6 +1,6 @@ spring: datasource: - url: jdbc:postgresql://localhost:5435/mydb + url: ${DB_URL:jdbc:postgresql://localhost:5435/mydb?sslmode=disable} username: headporter password: ENC(Gg03NzjjcVOrFSXoBrPEusNgDNbYOPO4) driver-class-name: org.postgresql.Driver From 4a97610eaf61e93516a8a4b0a068ee98e9a600a3 Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Thu, 16 Jul 2026 10:20:45 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat=20-=20=EC=A0=84=EB=B0=98=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpecialsourceHomepageApplication.java | 2 + .../config/VisitorInterceptor.java | 79 +++++++++++++++++++ .../specialsource/config/WebMvcConfig.java | 20 +++++ .../controller/AnalyticsController.java | 27 +++++++ .../controller/FileSyncController.java | 52 ++++++++++++ .../entity/DailyVisitorStats.java | 21 +++++ .../specialsource/entity/VisitorLog.java | 31 ++++++++ .../DailyVisitorStatsRepository.java | 21 +++++ .../repository/VisitorLogRepository.java | 40 ++++++++++ .../scheduler/VisitorScheduler.java | 32 ++++++++ .../service/AnalyticsService.java | 67 ++++++++++++++++ src/main/resources/application.yaml | 1 + 12 files changed, 393 insertions(+) create mode 100644 src/main/java/company/specialsource/config/VisitorInterceptor.java create mode 100644 src/main/java/company/specialsource/config/WebMvcConfig.java create mode 100644 src/main/java/company/specialsource/controller/AnalyticsController.java create mode 100644 src/main/java/company/specialsource/controller/FileSyncController.java create mode 100644 src/main/java/company/specialsource/entity/DailyVisitorStats.java create mode 100644 src/main/java/company/specialsource/entity/VisitorLog.java create mode 100644 src/main/java/company/specialsource/repository/DailyVisitorStatsRepository.java create mode 100644 src/main/java/company/specialsource/repository/VisitorLogRepository.java create mode 100644 src/main/java/company/specialsource/scheduler/VisitorScheduler.java create mode 100644 src/main/java/company/specialsource/service/AnalyticsService.java diff --git a/src/main/java/company/specialsource/SpecialsourceHomepageApplication.java b/src/main/java/company/specialsource/SpecialsourceHomepageApplication.java index a633f36..96c4e9a 100644 --- a/src/main/java/company/specialsource/SpecialsourceHomepageApplication.java +++ b/src/main/java/company/specialsource/SpecialsourceHomepageApplication.java @@ -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) { diff --git a/src/main/java/company/specialsource/config/VisitorInterceptor.java b/src/main/java/company/specialsource/config/VisitorInterceptor.java new file mode 100644 index 0000000..ccb6aca --- /dev/null +++ b/src/main/java/company/specialsource/config/VisitorInterceptor.java @@ -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; + } +} diff --git a/src/main/java/company/specialsource/config/WebMvcConfig.java b/src/main/java/company/specialsource/config/WebMvcConfig.java new file mode 100644 index 0000000..123bdaf --- /dev/null +++ b/src/main/java/company/specialsource/config/WebMvcConfig.java @@ -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 자체는 중복 카운팅 차단 + } +} \ No newline at end of file diff --git a/src/main/java/company/specialsource/controller/AnalyticsController.java b/src/main/java/company/specialsource/controller/AnalyticsController.java new file mode 100644 index 0000000..8bf1069 --- /dev/null +++ b/src/main/java/company/specialsource/controller/AnalyticsController.java @@ -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> getLiveNetworkStats() { + Map liveStats = analyticsService.getRealTimeDashboardStats(); + return ResponseEntity.ok(liveStats); + } +} \ No newline at end of file diff --git a/src/main/java/company/specialsource/controller/FileSyncController.java b/src/main/java/company/specialsource/controller/FileSyncController.java new file mode 100644 index 0000000..b166b42 --- /dev/null +++ b/src/main/java/company/specialsource/controller/FileSyncController.java @@ -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 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()); + } + } +} \ No newline at end of file diff --git a/src/main/java/company/specialsource/entity/DailyVisitorStats.java b/src/main/java/company/specialsource/entity/DailyVisitorStats.java new file mode 100644 index 0000000..9358aaf --- /dev/null +++ b/src/main/java/company/specialsource/entity/DailyVisitorStats.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/company/specialsource/entity/VisitorLog.java b/src/main/java/company/specialsource/entity/VisitorLog.java new file mode 100644 index 0000000..10a5696 --- /dev/null +++ b/src/main/java/company/specialsource/entity/VisitorLog.java @@ -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(); +} \ No newline at end of file diff --git a/src/main/java/company/specialsource/repository/DailyVisitorStatsRepository.java b/src/main/java/company/specialsource/repository/DailyVisitorStatsRepository.java new file mode 100644 index 0000000..85bbf2f --- /dev/null +++ b/src/main/java/company/specialsource/repository/DailyVisitorStatsRepository.java @@ -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 { + + // 💡 일일 통계 정산 데이터는 JPA가 기본 제공하는 save() 메서드만으로도 + // 새벽마다 적재(Insert/Update)하는 비즈니스 로직을 에러 없이 완벽하게 수행할 수 있습니다. + + /* 나중에 레트로 대시보드 기능을 확장하여 + "최근 일주일간 일별 방문자 추이 그래프" 같은 통계를 프론트엔드에 뿌리고 싶다면, + 아래와 같은 쿼리 메서드를 여기에 추가해서 사용하시면 아주 유용합니다. + + List findByVisitDateBetweenOrderByVisitDateAsc(LocalDate start, LocalDate end); + */ +} \ No newline at end of file diff --git a/src/main/java/company/specialsource/repository/VisitorLogRepository.java b/src/main/java/company/specialsource/repository/VisitorLogRepository.java new file mode 100644 index 0000000..4d4309e --- /dev/null +++ b/src/main/java/company/specialsource/repository/VisitorLogRepository.java @@ -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 { + + // 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); +} \ No newline at end of file diff --git a/src/main/java/company/specialsource/scheduler/VisitorScheduler.java b/src/main/java/company/specialsource/scheduler/VisitorScheduler.java new file mode 100644 index 0000000..ae465d7 --- /dev/null +++ b/src/main/java/company/specialsource/scheduler/VisitorScheduler.java @@ -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); + } + } +} \ No newline at end of file diff --git a/src/main/java/company/specialsource/service/AnalyticsService.java b/src/main/java/company/specialsource/service/AnalyticsService.java new file mode 100644 index 0000000..cdd15ae --- /dev/null +++ b/src/main/java/company/specialsource/service/AnalyticsService.java @@ -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 getRealTimeDashboardStats() { + ZonedDateTime startOfToday = LocalDate.now().atStartOfDay(ZoneId.systemDefault()); + ZonedDateTime fiveMinutesAgo = ZonedDateTime.now().minusMinutes(5); + + Map 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일 경과 로그 원격 청소 완료."); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 15c5fc3..4419d4e 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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 로그 출력 From 4bfd92e0562f0d8ec807559284ec559f31d5545e Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Thu, 16 Jul 2026 15:53:36 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat=20-=20=EC=9D=B8=EA=B0=80=20url=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/company/specialsource/config/SecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/company/specialsource/config/SecurityConfig.java b/src/main/java/company/specialsource/config/SecurityConfig.java index f07efe9..3f59457 100644 --- a/src/main/java/company/specialsource/config/SecurityConfig.java +++ b/src/main/java/company/specialsource/config/SecurityConfig.java @@ -17,13 +17,13 @@ public class SecurityConfig { http .csrf(csrf -> csrf.disable()) // REST API 통신을 위해 CSRF 비활성화 .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/login").permitAll() // 로그인 API는 누구나 접근 가능 + // 로그인 API 및 파일 동기화 업로드 API는 인증 없이 누구나 접근 가능하도록 허용 + .requestMatchers("/api/login", "/api/sync/upload").permitAll() .anyRequest().authenticated() ); return http.build(); } - @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); // BCrypt 암호화 알고리즘 사용 From 03cb0064dd798d79f565e712e28559f29a0c4cc5 Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Mon, 20 Jul 2026 14:56:13 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat=20-=20=EC=9D=B8=EA=B0=80=20url=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specialsource/config/WebMvcConfig.java | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/java/company/specialsource/config/WebMvcConfig.java b/src/main/java/company/specialsource/config/WebMvcConfig.java index 123bdaf..f9d0a2f 100644 --- a/src/main/java/company/specialsource/config/WebMvcConfig.java +++ b/src/main/java/company/specialsource/config/WebMvcConfig.java @@ -1,20 +1,28 @@ package company.specialsource.config; -import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; @Configuration -@RequiredArgsConstructor -public class WebMvcConfig implements WebMvcConfigurer { +@EnableWebSecurity +public class WebMvcConfig { - private final VisitorInterceptor visitorInterceptor; + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + // 1. 🟢 외부 매크로의 POST 요청이 통과할 수 있도록 CSRF 보호 대상에서 업로드 API를 제외합니다. + .csrf(csrf -> csrf + .ignoringRequestMatchers("/api/sync/upload") + ) + // 2. 🟢 로그인이나 별도의 인증 토큰 없이도 접근할 수 있도록 허용합니다. + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/sync/upload").permitAll() + .anyRequest().authenticated() + ); - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(visitorInterceptor) - .addPathPatterns("/api/**") // 🟢 모든 API 주소로 들어오는 길목을 차단하여 감시 - .excludePathPatterns("/api/analytics/**", "/static/**", "/favicon.ico"); // 통계 조회 API 자체는 중복 카운팅 차단 + return http.build(); } } \ No newline at end of file From 200b28e9a1a3f56dc1617d35b6a4ed42b4e55c5d Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Mon, 20 Jul 2026 15:09:45 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat=20-=20=EC=9D=B8=EA=B0=80=20url=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specialsource/config/WebMvcConfig.java | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/src/main/java/company/specialsource/config/WebMvcConfig.java b/src/main/java/company/specialsource/config/WebMvcConfig.java index f9d0a2f..d098188 100644 --- a/src/main/java/company/specialsource/config/WebMvcConfig.java +++ b/src/main/java/company/specialsource/config/WebMvcConfig.java @@ -1,28 +1,20 @@ package company.specialsource.config; -import org.springframework.context.annotation.Bean; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration -@EnableWebSecurity -public class WebMvcConfig { +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - // 1. 🟢 외부 매크로의 POST 요청이 통과할 수 있도록 CSRF 보호 대상에서 업로드 API를 제외합니다. - .csrf(csrf -> csrf - .ignoringRequestMatchers("/api/sync/upload") - ) - // 2. 🟢 로그인이나 별도의 인증 토큰 없이도 접근할 수 있도록 허용합니다. - .authorizeHttpRequests(auth -> auth - .requestMatchers("/api/sync/upload").permitAll() - .anyRequest().authenticated() - ); + private final VisitorInterceptor visitorInterceptor; - return http.build(); + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(visitorInterceptor) +// .addPathPatterns("/api/**") // 🟢 모든 API 주소로 들어오는 길목을 차단하여 감시 + .excludePathPatterns("/api/analytics/**", "/static/**", "/favicon.ico"); // 통계 조회 API 자체는 중복 카운팅 차단 } } \ No newline at end of file From 618f1e714c072aff77208b6d8936c4790c3aef79 Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Mon, 20 Jul 2026 15:17:35 +0900 Subject: [PATCH 07/11] =?UTF-8?q?fix=20-=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FileSyncController.java | 77 ++++++++++--------- 1 file changed, 42 insertions(+), 35 deletions(-) diff --git a/src/main/java/company/specialsource/controller/FileSyncController.java b/src/main/java/company/specialsource/controller/FileSyncController.java index b166b42..72cdf67 100644 --- a/src/main/java/company/specialsource/controller/FileSyncController.java +++ b/src/main/java/company/specialsource/controller/FileSyncController.java @@ -1,52 +1,59 @@ package company.specialsource.controller; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; +import jakarta.servlet.http.HttpServletRequest; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; - +import org.springframework.web.multipart.MultipartHttpServletRequest; +import java.io.InputStream; +import java.io.FileOutputStream; 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 uploadFileFromPhone(@RequestParam("file") MultipartFile file) { - if (file.isEmpty()) { - log.warn("⚠️ [Sync] 수신된 파일이 비어 있습니다."); - return ResponseEntity.badRequest().body("ERR: EMPTY_FILE"); - } + @PostMapping("/api/sync/upload") + public ResponseEntity uploadFile(HttpServletRequest request) { + String contentType = request.getContentType(); try { - // 저장 경로 폴더가 없으면 자동으로 생성 - File directory = new File(NAS_STORAGE_PATH); - if (!directory.exists()) { - directory.mkdirs(); - log.info("📁 [Sync] 업로드 저장 폴더를 새로 생성했습니다: {}", NAS_STORAGE_PATH); + // 1. 기존 웹 프론트엔드(Vue)에서 보낸 폼 데이터(Multipart) 처리 + if (contentType != null && contentType.startsWith("multipart/form-data")) { + MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request; + MultipartFile multipartFile = multipartRequest.getFile("file"); + + if (multipartFile != null && !multipartFile.isEmpty()) { + // 💾 기존에 사용하시던 파일 저장 로직을 여기에 그대로 수행하면 됩니다. + // 예: multipartFile.transferTo(new File("기존 저장 경로")); + System.out.println("웹 프론트엔드로부터 멀티파트 파일 수신 완료: " + multipartFile.getOriginalFilename()); + } + } + // 2. MacroDroid 매크로에서 보낸 순수 바이너리 스트림(Octet-Stream 등) 처리 + else { + // 저장될 임시 파일명 정의 (타임스탬프 조합) + String fileName = "macro_sync_" + System.currentTimeMillis() + ".jpg"; + + // ⚠️ 유저님의 나스 환경에 맞는 실제 업로드 절대 경로를 입력해 주세요. + File targetFile = new File("/volume1/docker/upload_dir/" + 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("MacroDroid로부터 바이너리 파일 수신 및 저장 완료: " + fileName); } - // 핸드폰에서 온 원본 파일명 그대로 파일 객체 생성 - File destination = new File(directory, file.getOriginalFilename()); + return ResponseEntity.ok("Upload Success"); - // 물리 디스크(나스 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()); + } catch (Exception e) { + e.printStackTrace(); + return ResponseEntity.internalServerError().body("Upload Error: " + e.getMessage()); } } } \ No newline at end of file From 12ab492a71750b1ad5fb9d9de4c569cce25d6ae6 Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Mon, 20 Jul 2026 16:17:20 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix=20-=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FileSyncController.java | 77 ++++++++++--------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/src/main/java/company/specialsource/controller/FileSyncController.java b/src/main/java/company/specialsource/controller/FileSyncController.java index 72cdf67..c3425f0 100644 --- a/src/main/java/company/specialsource/controller/FileSyncController.java +++ b/src/main/java/company/specialsource/controller/FileSyncController.java @@ -3,53 +3,58 @@ 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 org.springframework.web.multipart.MultipartFile; -import org.springframework.web.multipart.MultipartHttpServletRequest; -import java.io.InputStream; -import java.io.FileOutputStream; + import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; @RestController public class FileSyncController { + // 🟢 @RequestParam을 추가하여 마이크로드로이드가 보내는 'filename' 값을 전달받습니다. @PostMapping("/api/sync/upload") - public ResponseEntity uploadFile(HttpServletRequest request) { - String contentType = request.getContentType(); + public ResponseEntity uploadFile( + HttpServletRequest request, + @RequestParam(value = "filename", required = false) String originalFileName) { try { - // 1. 기존 웹 프론트엔드(Vue)에서 보낸 폼 데이터(Multipart) 처리 - if (contentType != null && contentType.startsWith("multipart/form-data")) { - MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request; - MultipartFile multipartFile = multipartRequest.getFile("file"); - - if (multipartFile != null && !multipartFile.isEmpty()) { - // 💾 기존에 사용하시던 파일 저장 로직을 여기에 그대로 수행하면 됩니다. - // 예: multipartFile.transferTo(new File("기존 저장 경로")); - System.out.println("웹 프론트엔드로부터 멀티파트 파일 수신 완료: " + multipartFile.getOriginalFilename()); - } - } - // 2. MacroDroid 매크로에서 보낸 순수 바이너리 스트림(Octet-Stream 등) 처리 - else { - // 저장될 임시 파일명 정의 (타임스탬프 조합) - String fileName = "macro_sync_" + System.currentTimeMillis() + ".jpg"; - - // ⚠️ 유저님의 나스 환경에 맞는 실제 업로드 절대 경로를 입력해 주세요. - File targetFile = new File("/volume1/docker/upload_dir/" + 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("MacroDroid로부터 바이너리 파일 수신 및 저장 완료: " + fileName); + // 1. 저장할 파일명 결정 로직 + String fileName; + if (originalFileName != null && !originalFileName.isEmpty()) { + // 마이크로드로이드가 이름을 보내준 경우 그 이름(확장자 포함)을 그대로 사용 + fileName = originalFileName; + } else { + // 이름을 찾지 못한 경우의 예비용 이름 (확장자를 알 수 없으므로 .dat로 임시 지정) + fileName = "unknown_file_" + System.currentTimeMillis() + ".dat"; } - return ResponseEntity.ok("Upload Success"); + // 2. 도커 내부의 상대 경로로 폴더 지정 + String uploadDirPath = "./upload/"; + File uploadDir = new File(uploadDirPath); + + // 3. 폴더가 존재하지 않으면 자동으로 생성 + if (!uploadDir.exists()) { + uploadDir.mkdirs(); + } + + // 4. 결정된 파일명으로 최종 저장 경로 생성 + File targetFile = new File(uploadDir, fileName); + + // 5. 파일 데이터 수신 및 쓰기 + 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 Success: " + fileName); } catch (Exception e) { e.printStackTrace(); From 0536a7fd8901972890ed0db36fa2ffacaf6fd2a3 Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Mon, 20 Jul 2026 16:25:23 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix=20-=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FileSyncController.java | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/main/java/company/specialsource/controller/FileSyncController.java b/src/main/java/company/specialsource/controller/FileSyncController.java index c3425f0..c7e362b 100644 --- a/src/main/java/company/specialsource/controller/FileSyncController.java +++ b/src/main/java/company/specialsource/controller/FileSyncController.java @@ -13,36 +13,30 @@ import java.io.InputStream; @RestController public class FileSyncController { - // 🟢 @RequestParam을 추가하여 마이크로드로이드가 보내는 'filename' 값을 전달받습니다. @PostMapping("/api/sync/upload") public ResponseEntity uploadFile( HttpServletRequest request, @RequestParam(value = "filename", required = false) String originalFileName) { try { - // 1. 저장할 파일명 결정 로직 - String fileName; - if (originalFileName != null && !originalFileName.isEmpty()) { - // 마이크로드로이드가 이름을 보내준 경우 그 이름(확장자 포함)을 그대로 사용 - fileName = originalFileName; - } else { - // 이름을 찾지 못한 경우의 예비용 이름 (확장자를 알 수 없으므로 .dat로 임시 지정) - fileName = "unknown_file_" + System.currentTimeMillis() + ".dat"; + // 1. 🟢 파일명 검증: 원래 명칭이 비어있거나 누락되었다면 요청을 거부합니다. + if (originalFileName == null || originalFileName.trim().isEmpty()) { + return ResponseEntity.badRequest().body("Error: 원래 파일명이 전달되지 않았습니다."); } - // 2. 도커 내부의 상대 경로로 폴더 지정 - String uploadDirPath = "./upload/"; + // 2. 유저님이 지정하신 나스의 실제 절대 경로 고정 + String uploadDirPath = "/volume1/docker/upload_dir/"; File uploadDir = new File(uploadDirPath); - // 3. 폴더가 존재하지 않으면 자동으로 생성 + // 디렉토리가 없으면 안전하게 자동 생성 if (!uploadDir.exists()) { uploadDir.mkdirs(); } - // 4. 결정된 파일명으로 최종 저장 경로 생성 - File targetFile = new File(uploadDir, fileName); + // 3. 🟢 파일명 가공 없이 원래 명칭 그대로 저장 파일 객체 정의 + File targetFile = new File(uploadDir, originalFileName); - // 5. 파일 데이터 수신 및 쓰기 + // 4. 바이너리 스트림을 읽어 파일 작성 진행 try (InputStream is = request.getInputStream(); FileOutputStream fos = new FileOutputStream(targetFile)) { @@ -53,8 +47,8 @@ public class FileSyncController { } } - System.out.println("✅ 파일 업로드 성공: " + targetFile.getAbsolutePath()); - return ResponseEntity.ok("Upload Success: " + fileName); + System.out.println("✅ 원래 명칭 그대로 업로드 완료: " + targetFile.getAbsolutePath()); + return ResponseEntity.ok("Upload Success: " + originalFileName); } catch (Exception e) { e.printStackTrace(); From 4a022a4202c946fe21fda5fd8624ec7129ea6aee Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Mon, 20 Jul 2026 16:31:03 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix=20-=20=ED=8C=8C=EC=9D=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/FileSyncController.java | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/main/java/company/specialsource/controller/FileSyncController.java b/src/main/java/company/specialsource/controller/FileSyncController.java index c7e362b..dbaf854 100644 --- a/src/main/java/company/specialsource/controller/FileSyncController.java +++ b/src/main/java/company/specialsource/controller/FileSyncController.java @@ -18,25 +18,29 @@ public class FileSyncController { HttpServletRequest request, @RequestParam(value = "filename", required = false) String originalFileName) { + // 🟢 디버깅용 로그: MacroDroid가 주소창에 무엇을 실어서 보냈는지 포착합니다. + System.out.println("🔗 전체 쿼리 스트링: " + request.getQueryString()); + System.out.println("📂 수신된 파일명 변수값: " + originalFileName); + try { - // 1. 🟢 파일명 검증: 원래 명칭이 비어있거나 누락되었다면 요청을 거부합니다. - if (originalFileName == null || originalFileName.trim().isEmpty()) { - return ResponseEntity.badRequest().body("Error: 원래 파일명이 전달되지 않았습니다."); + String fileName = originalFileName; + + // 🟢 파일명이 누락되었을 때 400 에러로 터뜨리지 않고, 무엇이 잘못되었는지 로그를 남긴 후 임시 저장합니다. + if (fileName == null || fileName.trim().isEmpty()) { + System.out.println("⚠️ 경고: MacroDroid에서 filename 파라미터가 빈 값으로 전송되었습니다."); + fileName = "missing_name_" + System.currentTimeMillis() + ".dat"; } - // 2. 유저님이 지정하신 나스의 실제 절대 경로 고정 + // 나스 실제 절대 경로 설정 String uploadDirPath = "/volume1/docker/upload_dir/"; File uploadDir = new File(uploadDirPath); - - // 디렉토리가 없으면 안전하게 자동 생성 if (!uploadDir.exists()) { uploadDir.mkdirs(); } - // 3. 🟢 파일명 가공 없이 원래 명칭 그대로 저장 파일 객체 정의 - File targetFile = new File(uploadDir, originalFileName); + File targetFile = new File(uploadDir, fileName); - // 4. 바이너리 스트림을 읽어 파일 작성 진행 + // 파일 저장 진행 try (InputStream is = request.getInputStream(); FileOutputStream fos = new FileOutputStream(targetFile)) { @@ -47,8 +51,8 @@ public class FileSyncController { } } - System.out.println("✅ 원래 명칭 그대로 업로드 완료: " + targetFile.getAbsolutePath()); - return ResponseEntity.ok("Upload Success: " + originalFileName); + System.out.println("✅ 파일 처리 완료: " + targetFile.getAbsolutePath()); + return ResponseEntity.ok("Upload Processed: " + fileName); } catch (Exception e) { e.printStackTrace(); From b4ccc880930bd2767597f8fd47cfeeb5b06fbd3d Mon Sep 17 00:00:00 2001 From: "sungjin.choi" Date: Mon, 20 Jul 2026 18:08:27 +0900 Subject: [PATCH 11/11] =?UTF-8?q?chore=20-=20=EC=97=85=EB=A1=9C=EB=93=9C?= =?UTF-8?q?=20=EC=9A=A9=EB=9F=89=20=EC=A0=9C=ED=95=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application.yaml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 4419d4e..158dc1b 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -1,4 +1,9 @@ spring: + servlet: + multipart: + enabled: true + max-file-size: 5GB # 🟢 개별 파일 허용 용량을 5기가로 대폭 확장 + max-request-size: 5GB # 🟢 전체 HTTP 요청 허용 용량도 5기가로 맞춤 datasource: url: ${DB_URL:jdbc:postgresql://localhost:5435/mydb?sslmode=disable} username: headporter @@ -14,6 +19,11 @@ spring: hibernate: format_sql: true +server: + tomcat: + max-swallow-size: 5GB # 🟢 톰캣 서버가 업로드 중에 차단하지 않도록 크기 동기화 (또는 -1로 설정 시 무제한) + connection-timeout: 3600000 # 🟢 대용량 전송 시간을 고려하여 타임아웃을 1시간(3,600,000 밀리초)으로 넉넉히 연장 + jasypt: encryptor: password: 81sungjin1015