Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> allKeys = redisTemplate.keys(keyPattern);

// Redis에서 어제 날짜의 모든 키 조회
Set<String> keys = redisTemplate.keys(keyPattern);
if (allKeys == null || allKeys.isEmpty()) {
log.info("No metrics found in Redis");
return;
}

// 오늘 이전 날짜의 키만 필터링
Set<String> 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<Object, Object> 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<DailyCategoryUsage> categoryUsages = extractCategoryUsages(subjectId, yesterday, redisData);
List<DailyCategoryUsage> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ public interface RawDataRepository extends JpaRepository<RawData, RawDataId> {
RawData findFirstBySubjectIdOrderByTimestampDesc(UUID subjectId);

List<RawData> findBySubjectIdAndTimestampAfter(UUID subjectId, Instant cutoff);

List<RawData> findBySubjectIdAndTimestampBetween(UUID subjectId, Instant startTime, Instant endTime);
}
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,19 @@ public ResponseEntity<String> createRawDataTestUser(
List<RawData> newDataList = new ArrayList<>();

try {
Instant cutoff = Instant.now().minus(Duration.ofHours(24));
List<RawData> 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<RawData> 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);
Expand All @@ -66,36 +76,15 @@ public ResponseEntity<String> createRawDataTestUser(

newDataList.addAll(recentData);

log.info("🔍 Checking for duplicates in Redis ({} total items)...", newDataList.size());
int duplicateCount = 0;
List<RawData> 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);
Expand Down
56 changes: 18 additions & 38 deletions k8s/ingress/ingress.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,89 +4,69 @@ 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:
service:
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:
service:
name: gateway
port:
number: 8088

- path: /v3/api-docs
pathType: Prefix
backend:
service:
name: gateway
port:
number: 8088

- path: /webjars
pathType: Prefix
backend:
service:
name: gateway
port:
number: 8088
number: 8088