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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def coverageExcludePackages = [
'**/notification/**',
'**/usage/**',
'**/util/**',
'**/core/**',
]

// JaCoCo용 제외 패턴 (클래스 파일)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.project.core.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.project.core.controller.dto.request.UpdateBatchScheduleRequest;
import com.project.core.util.BatchTriggerClient;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@RestController
@RequestMapping("/admin/batch/usage")
@RequiredArgsConstructor
@Slf4j
public class UsageBatchAdminController {

private final BatchTriggerClient batchTriggerClient;

@PutMapping("/schedule")
public ResponseEntity<Void> updateSchedule(@RequestBody UpdateBatchScheduleRequest request) {
batchTriggerClient.schedule(request.job(), request.cron());
return ResponseEntity.ok().build();
}
}
25 changes: 25 additions & 0 deletions src/main/java/com/project/core/controller/UsageController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.project.core.controller;

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 com.project.core.controller.dto.response.UsageDashboardResponse;
import com.project.core.service.UsageService;

import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/usage")
@RequiredArgsConstructor
public class UsageController {

private final UsageService usageService;

@GetMapping
public ResponseEntity<UsageDashboardResponse> getUsageDashboard() {

return ResponseEntity.ok(usageService.getDashboard());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.project.core.controller.dto.response;

import java.util.List;

import com.project.core.controller.dto.BatchFailureLogDto;
import com.project.core.controller.dto.BatchSummaryDto;
import com.project.core.controller.dto.GrafanaInfoDto;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class UsageDashboardResponse {
private List<BatchSummaryDto> batchJobs;
private GrafanaInfoDto grafana;
private List<BatchFailureLogDto> recentFailures;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
@RequiredArgsConstructor
public enum BatchJobType {
INVOICE_ITEM_JOB("invoiceItemJob", "INVOICE_ITEM"),
INVOICE_JOB("invoiceJob", "INVOICE");
INVOICE_JOB("invoiceJob", "INVOICE"),
USAGE_AGGREGATION_JOB("usageAggregationJob", "USAGE"),
USAGE_NOTIFICATION_JOB("usageNotificationJob", "USAGE");

private final String jobName;
private final String title;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.project.core.infra.repository.usage;

import java.util.List;

import com.project.core.controller.dto.BatchFailureLogDto;

public interface UsageBatchFailureQueryRepository {
List<BatchFailureLogDto> findRecentFailures(int limit);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.project.core.infra.repository.usage;

import java.util.List;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import com.project.core.controller.dto.BatchFailureLogDto;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class UsageBatchFailureQueryRepositoryImpl implements UsageBatchFailureQueryRepository {

private final JdbcTemplate jdbcTemplate;

@Override
public List<BatchFailureLogDto> findRecentFailures(int limit) {

String sql =
"""
SELECT
ji.JOB_NAME,
je.STATUS,
je.EXIT_CODE,
je.EXIT_MESSAGE,
je.END_TIME
FROM BATCH_JOB_INSTANCE ji
JOIN BATCH_JOB_EXECUTION je
ON ji.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID
WHERE je.STATUS = 'FAILED'
AND ji.JOB_NAME IN ('usageAggregationJob', 'usageNotificationJob')
ORDER BY je.END_TIME DESC
LIMIT ?
""";

return jdbcTemplate.query(
sql,
ps -> ps.setInt(1, limit),
(rs, rowNum) ->
new BatchFailureLogDto(
rs.getString("JOB_NAME"),
rs.getString("STATUS"),
rs.getString("EXIT_CODE"),
rs.getString("EXIT_MESSAGE"),
rs.getTimestamp("END_TIME").toLocalDateTime()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.project.core.infra.repository.usage;

import java.util.List;

import com.project.core.controller.dto.BatchSummaryDto;

public interface UsageBatchJobQueryRepository {
List<BatchSummaryDto> findLatestJobSummaries();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.project.core.infra.repository.usage;

import java.time.LocalDateTime;
import java.util.List;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import com.project.core.controller.dto.BatchSummaryDto;

import lombok.RequiredArgsConstructor;

@Repository
@RequiredArgsConstructor
public class UsageBatchJobQueryRepositoryImpl implements UsageBatchJobQueryRepository {

private final JdbcTemplate jdbcTemplate;

@Override
public List<BatchSummaryDto> findLatestJobSummaries() {

String sql =
"""
SELECT
ji.JOB_NAME,
je.STATUS,
je.EXIT_CODE,
je.EXIT_MESSAGE,
je.START_TIME,
je.END_TIME,
EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000 AS DURATION_MS
FROM BATCH_JOB_INSTANCE ji
JOIN BATCH_JOB_EXECUTION je
ON ji.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID
WHERE ji.JOB_NAME IN ('usageAggregationJob', 'usageNotificationJob')
AND je.JOB_EXECUTION_ID IN (
SELECT MAX(je2.JOB_EXECUTION_ID)
FROM BATCH_JOB_EXECUTION je2
JOIN BATCH_JOB_INSTANCE ji2
ON ji2.JOB_INSTANCE_ID = je2.JOB_INSTANCE_ID
WHERE ji2.JOB_NAME IN ('usageAggregationJob', 'usageNotificationJob')
GROUP BY ji2.JOB_NAME
)
ORDER BY ji.JOB_NAME
""";

return jdbcTemplate.query(
sql,
(rs, rowNum) -> {
var startTimestamp = rs.getTimestamp("START_TIME");
var endTimestamp = rs.getTimestamp("END_TIME");

LocalDateTime startTime =
startTimestamp != null ? startTimestamp.toLocalDateTime() : null;

LocalDateTime endTime =
endTimestamp != null ? endTimestamp.toLocalDateTime() : null;

return BatchSummaryDto.builder()
.jobName(rs.getString("JOB_NAME"))
.status(rs.getString("STATUS"))
.exitCode(rs.getString("EXIT_CODE"))
.exitMessage(rs.getString("EXIT_MESSAGE"))
.startTime(startTime)
.endTime(endTime)
.durationMs(rs.getLong("DURATION_MS"))
// cron 관련 필드는 Service에서 채움
.build();
});
}
}
119 changes: 119 additions & 0 deletions src/main/java/com/project/core/service/UsageService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package com.project.core.service;

import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.List;
import java.util.Locale;

import org.springframework.scheduling.support.CronExpression;
import org.springframework.stereotype.Service;

import com.cronutils.descriptor.CronDescriptor;
import com.cronutils.model.Cron;
import com.cronutils.model.CronType;
import com.cronutils.model.definition.CronDefinitionBuilder;
import com.cronutils.parser.CronParser;
import com.project.core.controller.dto.BatchSummaryDto;
import com.project.core.controller.dto.GrafanaInfoDto;
import com.project.core.controller.dto.response.BatchScheduleResponse;
import com.project.core.controller.dto.response.UsageDashboardResponse;
import com.project.core.infra.repository.usage.UsageBatchFailureQueryRepository;
import com.project.core.infra.repository.usage.UsageBatchJobQueryRepository;
import com.project.core.util.BatchScheduleClient;
import com.project.global.config.BillingProperties;
import com.project.global.exception.code.domain.core.CoreErrorCode;
import com.project.global.exception.core.EntityNotFoundException;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Service
@RequiredArgsConstructor
@Slf4j
public class UsageService {

private final UsageBatchJobQueryRepository usageBatchJobQueryRepository;
private final UsageBatchFailureQueryRepository usageBatchFailureQueryRepository;
private final BatchScheduleClient batchScheduleClient;
private final BillingProperties billingProperties;

public UsageDashboardResponse getDashboard() {

try {
// 1️⃣ 최신 배치 실행 요약 조회
List<BatchSummaryDto> jobs = usageBatchJobQueryRepository.findLatestJobSummaries();

// 2️⃣ Cloud Function 스케줄 정보 결합
List<BatchSummaryDto> enrichedJobs =
jobs.stream().map(this::enrichWithSchedule).toList();

return UsageDashboardResponse.builder()
.batchJobs(enrichedJobs)
.grafana(
GrafanaInfoDto.builder()
.iframeUrl(billingProperties.getIframeUrl())
.refreshInterval(billingProperties.getRefreshInterval())
.build())
.recentFailures(usageBatchFailureQueryRepository.findRecentFailures(10))
.build();

} catch (Exception e) {
log.error("[UsageDashboard] 대시보드 빌딩 실패", e);
throw new EntityNotFoundException(CoreErrorCode.DASHBOARD_NOT_FOUND);
}
}

/** 배치 실행 요약 + Cloud Function 스케줄 정보 결합 */
private BatchSummaryDto enrichWithSchedule(BatchSummaryDto job) {

return batchScheduleClient
.getSchedule(job.getJobName())
.map(schedule -> applySchedule(job, schedule))
.orElseGet(() -> job.toBuilder().cronDescription("수동 실행").build());
}

/** cron / 설명 / 다음 실행 시각 계산 (최종) */
private BatchSummaryDto applySchedule(BatchSummaryDto job, BatchScheduleResponse schedule) {

// 1️⃣ 비활성화 스케줄
if (!schedule.isEnabled() || schedule.getCron() == null) {
return job.toBuilder().cronDescription("비활성화").build();
}

String cronStr = schedule.getCron();
String timeZone = schedule.getTimeZone(); // ex) Asia/Seoul

try {
// 2️⃣ 타임존 (Cloud Function 기준)
ZoneId zoneId = ZoneId.of(timeZone);

// ✅ Spring용 크론 보정 (6필드)
String springCronStr = cronStr;
if (cronStr.split(" ").length == 5) {
springCronStr = "0 " + cronStr;
}

CronExpression springCron = CronExpression.parse(springCronStr);

ZonedDateTime nextFire = springCron.next(ZonedDateTime.now(zoneId));

// ✅ cron-utils (UNIX)
CronParser parser =
new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX));
Cron cron = parser.parse(cronStr);

String cronDescription = CronDescriptor.instance(Locale.KOREAN).describe(cron);

return job.toBuilder()
.cronExpression(cronStr)
.cronDescription(cronDescription)
.nextFireTime(nextFire != null ? nextFire.toLocalDateTime() : null)
.build();

} catch (Exception e) {
log.warn("[UsageDashboard] cron 처리 실패 job={}, cron={}", job.getJobName(), cronStr, e);

return job.toBuilder().cronExpression(cronStr).cronDescription("잘못된 크론식").build();
}
}
}