diff --git a/UsageService/src/main/java/screentimeai/application/DailyMetricsAggregator.java b/UsageService/src/main/java/screentimeai/application/DailyMetricsAggregator.java index 648bbee..5e24510 100644 --- a/UsageService/src/main/java/screentimeai/application/DailyMetricsAggregator.java +++ b/UsageService/src/main/java/screentimeai/application/DailyMetricsAggregator.java @@ -147,6 +147,7 @@ public ThreeDayUsageResponse getThreeDayUsage(UUID subjectId) { for (DailyUserMetrics metric : pastMetrics) { dailySummaries.add(DailyUsageSummary.builder() .date(metric.getSummaryDate()) + .totalMinutes(metric.getTotalUsageMinutes()) .dawnMinutes(metric.getDawnMinutes()) .morningMinutes(metric.getMorningMinutes()) .afternoonMinutes(metric.getAfternoonMinutes()) diff --git a/UsageService/src/main/java/screentimeai/application/DailyMetricsBatchService.java b/UsageService/src/main/java/screentimeai/application/DailyMetricsBatchService.java index 1c4fd29..2f0acd6 100644 --- a/UsageService/src/main/java/screentimeai/application/DailyMetricsBatchService.java +++ b/UsageService/src/main/java/screentimeai/application/DailyMetricsBatchService.java @@ -95,60 +95,85 @@ public void saveDailyMetricsToDB() { log.info("Starting daily metrics batch job..."); LocalDate yesterday = LocalDate.now().minusDays(1); - String keyPattern = REDIS_KEY_PREFIX + "*:" + yesterday; + // Redis에서 모든 daily_metrics 키 조회 + String keyPattern = REDIS_KEY_PREFIX + "*"; + Set allKeys = redisTemplate.keys(keyPattern); - // Redis에서 어제 날짜의 모든 키 조회 - Set keys = redisTemplate.keys(keyPattern); + if (allKeys == null || allKeys.isEmpty()) { + log.info("No metrics found in Redis"); + return; + } + + // 오늘 이전 날짜의 키만 필터링 + Set keysToProcess = new java.util.HashSet<>(); + for (String key : allKeys) { + try { + // daily_metrics:UUID:DATE 형식에서 DATE 추출 + String[] parts = key.split(":"); + if (parts.length >= 3) { + LocalDate keyDate = LocalDate.parse(parts[2]); + if (keyDate.isBefore(today)) { + keysToProcess.add(key); + } + } + } catch (Exception e) { + log.warn("Failed to parse date from key: {}", key); + } + } - if ( keys.isEmpty()) { - log.info("No metrics found for yesterday: {}", yesterday); + if (keysToProcess.isEmpty()) { + log.info("No metrics found before today ({})", today); return; } + log.info("Found {} keys to process (before {})", keysToProcess.size(), today); + int savedCount = 0; - for (String redisKey : keys) { + for (String redisKey : keysToProcess) { try { - // Redis 키에서 subject_id 추출 + // Redis 키에서 subject_id와 date 추출 UUID subjectId = extractSubjectId(redisKey); + String[] parts = redisKey.split(":"); + LocalDate date = LocalDate.parse(parts[2]); // Redis 데이터 조회 Map redisData = redisTemplate.opsForHash().entries(redisKey); // 1. DailyUserMetrics 저장 - DailyUserMetrics metrics = convertToMetrics(subjectId, yesterday, redisData); + DailyUserMetrics metrics = convertToMetrics(subjectId, date, redisData); metricsRepository.save(metrics); // 2. DailyCategoryUsage 저장 - List categoryUsages = extractCategoryUsages(subjectId, yesterday, redisData); + List categoryUsages = extractCategoryUsages(subjectId, date, redisData); if (!categoryUsages.isEmpty()) { categoryUsageRepository.saveAll(categoryUsages); } // 3. SNS 인사이트 계산 및 저장 try { - snsInsightService.calculateAndSave(subjectId, yesterday); - log.debug("SNS insight calculated for subject: {}, date: {}", subjectId, yesterday); + snsInsightService.calculateAndSave(subjectId, date); + log.debug("SNS insight calculated for subject: {}, date: {}", subjectId, date); } catch (Exception e) { - log.error("Failed to calculate SNS insight for subject: {}, date: {}", subjectId, yesterday, e); + log.error("Failed to calculate SNS insight for subject: {}, date: {}", subjectId, date, e); } // 4. 이벤트 발행 DailyMetricsSaved event = new DailyMetricsSaved(metrics); eventPublisher.publish("produceDailyMetricsSaved", event, "DailyMetricsSaved"); - log.info("Published DailyMetricsSaved event for subject: {}, date: {}", subjectId, yesterday); + log.info("Published DailyMetricsSaved event for subject: {}, date: {}", subjectId, date); redisTemplate.delete(redisKey); savedCount++; - log.debug("Saved metrics for subject: {}, date: {}", subjectId, yesterday); + log.debug("Saved metrics for subject: {}, date: {}", subjectId, date); } catch (Exception e) { log.error("Failed to process metrics for key: {}", redisKey, e); } } - log.info("Daily metrics batch job completed. Saved {} records for date: {}", savedCount, yesterday); + log.info("Daily metrics batch job completed. Saved {} records (all dates before {})", savedCount, today); } private UUID extractSubjectId(String redisKey) { diff --git a/UsageService/src/main/java/screentimeai/infra/repository/RawDataRepository.java b/UsageService/src/main/java/screentimeai/infra/repository/RawDataRepository.java index 83cfde0..6608491 100644 --- a/UsageService/src/main/java/screentimeai/infra/repository/RawDataRepository.java +++ b/UsageService/src/main/java/screentimeai/infra/repository/RawDataRepository.java @@ -17,4 +17,6 @@ public interface RawDataRepository extends JpaRepository { RawData findFirstBySubjectIdOrderByTimestampDesc(UUID subjectId); List findBySubjectIdAndTimestampAfter(UUID subjectId, Instant cutoff); + + List findBySubjectIdAndTimestampBetween(UUID subjectId, Instant startTime, Instant endTime); } diff --git a/UsageService/src/main/java/screentimeai/presentation/controller/RawDataController.java b/UsageService/src/main/java/screentimeai/presentation/controller/RawDataController.java index 13b8ec5..388ff30 100644 --- a/UsageService/src/main/java/screentimeai/presentation/controller/RawDataController.java +++ b/UsageService/src/main/java/screentimeai/presentation/controller/RawDataController.java @@ -55,9 +55,19 @@ public ResponseEntity createRawDataTestUser( List newDataList = new ArrayList<>(); try { - Instant cutoff = Instant.now().minus(Duration.ofHours(24)); - List recentData = rawDataRepository.findBySubjectIdAndTimestampAfter(subjectId, cutoff); - log.info("📊 DB fetch complete — found {} records (cutoff={})", recentData.size(), cutoff); + // 테스트 계정은 오늘 자정부터 현재까지 데이터만 조회 (배치는 어제까지 처리했으므로) + Instant now = Instant.now(); + // 사용자의 UTC offset 가져오기 (기본값: 9 - 한국 시간) + RawData latestRawData = rawDataRepository.findFirstBySubjectIdOrderByTimestampDesc(subjectId); + int utcOffset = (latestRawData != null && latestRawData.getUtcoffset() != null) + ? latestRawData.getUtcoffset() : 9; + + // 오늘 자정 계산 (사용자의 UTC offset 기준) + ZoneId zoneId = ZoneId.ofOffset("UTC", java.time.ZoneOffset.ofHours(utcOffset)); + Instant todayMidnight = java.time.LocalDate.now(zoneId).atStartOfDay(zoneId).toInstant(); + + List recentData = rawDataRepository.findBySubjectIdAndTimestampBetween(subjectId, todayMidnight, now); + log.info("📊 DB fetch complete — found {} records for today (midnight={}, now={})", recentData.size(), todayMidnight, now); if (recentData.isEmpty()) { log.warn("⚠ No recent data found for subjectId={}", subjectId); @@ -66,36 +76,15 @@ public ResponseEntity createRawDataTestUser( newDataList.addAll(recentData); - log.info("🔍 Checking for duplicates in Redis ({} total items)...", newDataList.size()); - int duplicateCount = 0; - List filteredList = new ArrayList<>(); - - for (RawData rawData : newDataList) { - String key = String.format("processed:%s:%d", - rawData.getSubjectId(), rawData.getTimestamp().toEpochMilli()); - - Boolean isNew = redisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofDays(3)); + // 테스트 계정은 시뮬레이션 목적이므로 멱등성 체크 없이 바로 처리 + log.info("🔄 Test account: Skipping deduplication, processing all {} records", newDataList.size()); - if (Boolean.TRUE.equals(isNew)) { - filteredList.add(rawData); - } else { - duplicateCount++; - log.debug("↩ Duplicate skipped: {}", key); - } - } - - log.info("✅ Redis deduplication complete — {} new, {} duplicates skipped", filteredList.size(), duplicateCount); - - if (!filteredList.isEmpty()) { - log.info("🚀 Aggregating {} records into Redis via metricsAggregator...", filteredList.size()); - metricsAggregator.aggregateRawDataBatch(filteredList); - log.info("🎯 Aggregation complete!"); - } else { - log.warn("⚠ No new data to aggregate (all duplicates?)"); - } + log.info("🚀 Aggregating {} records into Redis via metricsAggregator...", newDataList.size()); + metricsAggregator.aggregateRawDataBatch(newDataList); + log.info("🎯 Aggregation complete!"); - String resultMsg = String.format("Processed %d records (%d duplicates skipped)", - filteredList.size(), duplicateCount); + String resultMsg = String.format("Processed %d records (test mode: no deduplication)", + newDataList.size()); log.info("✅ Finished processing: {}", resultMsg); return ResponseEntity.ok(resultMsg); diff --git a/k8s/ingress/ingress.yaml b/k8s/ingress/ingress.yaml index f6ab3c3..51794ae 100644 --- a/k8s/ingress/ingress.yaml +++ b/k8s/ingress/ingress.yaml @@ -4,29 +4,32 @@ metadata: name: api-ingress namespace: screentimeai annotations: - # F5 Nginx Ingress Controller annotations - nginx.org/redirect-to-https: "true" - nginx.org/client-max-body-size: "50m" # 요청 본문 최대 크기 50MB - # Let's Encrypt 자동 인증서 발급 (cert-manager 사용 시) + # --- NGINX Ingress 기본 설정 --- + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/limit-rps: "10" + nginx.org/client-max-body-size: "50m" + + # --- cert-manager 설정 --- cert-manager.io/cluster-issuer: letsencrypt-prod - # .git 디렉토리 접근 차단 - nginx.org/server-snippets: | + + # --- 보안: .git 접근 차단 --- + nginx.ingress.kubernetes.io/server-snippet: | location ~ /\.git { deny all; return 404; } - # Rate limit to 10 requests per second - nginx.ingress.kubernetes.io/limit-rps: "10" spec: ingressClassName: nginx tls: - hosts: - api.yolang.shop secretName: api-yolang-shop-tls + rules: - host: api.yolang.shop http: paths: + # ✅ Gateway 전체 트래픽 라우팅 - path: / pathType: Prefix backend: @@ -34,41 +37,16 @@ spec: name: gateway port: number: 8088 - - path: /favicon.ico + + # ✅ Swagger UI & 관련 리소스 경로 + - path: /swagger-ui.html pathType: Exact backend: service: name: gateway port: number: 8088 - - path: /users - pathType: Prefix - backend: - service: - name: gateway - port: - number: 8088 - - path: /usage - pathType: Prefix - backend: - service: - name: gateway - port: - number: 8088 - - path: /insights - pathType: Prefix - backend: - service: - name: gateway - port: - number: 8088 - - path: /actuator - pathType: Prefix - backend: - service: - name: gateway - port: - number: 8088 + - path: /swagger-ui pathType: Prefix backend: @@ -76,6 +54,7 @@ spec: name: gateway port: number: 8088 + - path: /v3/api-docs pathType: Prefix backend: @@ -83,10 +62,11 @@ spec: name: gateway port: number: 8088 + - path: /webjars pathType: Prefix backend: service: name: gateway port: - number: 8088 \ No newline at end of file + number: 8088