From e4fbf6d382110a18ecc98b493188489c9acb4330 Mon Sep 17 00:00:00 2001 From: yechan <100present@naver.com> Date: Mon, 26 Jan 2026 21:57:05 +0900 Subject: [PATCH 1/2] =?UTF-8?q?UPLUSE-121=20feat:=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EB=9F=89=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20api=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UsageBatchAdminController.java | 28 +++++ .../core/controller/UsageController.java | 25 ++++ .../dto/response/UsageDashboardResponse.java | 18 +++ .../core/infra/entity/batch/BatchJobType.java | 4 +- .../repository/invoice/InvoiceRepository.java | 21 ---- .../UsageBatchFailureQueryRepository.java | 9 ++ .../UsageBatchFailureQueryRepositoryImpl.java | 49 ++++++++ .../usage/UsageBatchJobQueryRepository.java | 9 ++ .../UsageBatchJobQueryRepositoryImpl.java | 71 +++++++++++ .../project/core/service/UsageService.java | 119 ++++++++++++++++++ 10 files changed, 331 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/project/core/controller/UsageBatchAdminController.java create mode 100644 src/main/java/com/project/core/controller/UsageController.java create mode 100644 src/main/java/com/project/core/controller/dto/response/UsageDashboardResponse.java delete mode 100644 src/main/java/com/project/core/infra/repository/invoice/InvoiceRepository.java create mode 100644 src/main/java/com/project/core/infra/repository/usage/UsageBatchFailureQueryRepository.java create mode 100644 src/main/java/com/project/core/infra/repository/usage/UsageBatchFailureQueryRepositoryImpl.java create mode 100644 src/main/java/com/project/core/infra/repository/usage/UsageBatchJobQueryRepository.java create mode 100644 src/main/java/com/project/core/infra/repository/usage/UsageBatchJobQueryRepositoryImpl.java create mode 100644 src/main/java/com/project/core/service/UsageService.java diff --git a/src/main/java/com/project/core/controller/UsageBatchAdminController.java b/src/main/java/com/project/core/controller/UsageBatchAdminController.java new file mode 100644 index 0000000..551bb3e --- /dev/null +++ b/src/main/java/com/project/core/controller/UsageBatchAdminController.java @@ -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 updateSchedule(@RequestBody UpdateBatchScheduleRequest request) { + batchTriggerClient.schedule(request.job(), request.cron()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/project/core/controller/UsageController.java b/src/main/java/com/project/core/controller/UsageController.java new file mode 100644 index 0000000..bd61047 --- /dev/null +++ b/src/main/java/com/project/core/controller/UsageController.java @@ -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 getUsageDashboard() { + + return ResponseEntity.ok(usageService.getDashboard()); + } +} diff --git a/src/main/java/com/project/core/controller/dto/response/UsageDashboardResponse.java b/src/main/java/com/project/core/controller/dto/response/UsageDashboardResponse.java new file mode 100644 index 0000000..9e602e8 --- /dev/null +++ b/src/main/java/com/project/core/controller/dto/response/UsageDashboardResponse.java @@ -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 batchJobs; + private GrafanaInfoDto grafana; + private List recentFailures; +} diff --git a/src/main/java/com/project/core/infra/entity/batch/BatchJobType.java b/src/main/java/com/project/core/infra/entity/batch/BatchJobType.java index 0a79481..5cc96e0 100644 --- a/src/main/java/com/project/core/infra/entity/batch/BatchJobType.java +++ b/src/main/java/com/project/core/infra/entity/batch/BatchJobType.java @@ -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; diff --git a/src/main/java/com/project/core/infra/repository/invoice/InvoiceRepository.java b/src/main/java/com/project/core/infra/repository/invoice/InvoiceRepository.java deleted file mode 100644 index 84ce6f1..0000000 --- a/src/main/java/com/project/core/infra/repository/invoice/InvoiceRepository.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.project.core.infra.repository.invoice; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Repository; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class InvoiceRepository { - - private final JdbcTemplate jdbcTemplate; - - // public BatchSummaryDto selectExecutionSummary() { - // - // } - - // public List selectLatestJobExecution() { - // - // } -} diff --git a/src/main/java/com/project/core/infra/repository/usage/UsageBatchFailureQueryRepository.java b/src/main/java/com/project/core/infra/repository/usage/UsageBatchFailureQueryRepository.java new file mode 100644 index 0000000..e58382a --- /dev/null +++ b/src/main/java/com/project/core/infra/repository/usage/UsageBatchFailureQueryRepository.java @@ -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 findRecentFailures(int limit); +} diff --git a/src/main/java/com/project/core/infra/repository/usage/UsageBatchFailureQueryRepositoryImpl.java b/src/main/java/com/project/core/infra/repository/usage/UsageBatchFailureQueryRepositoryImpl.java new file mode 100644 index 0000000..2beb5a3 --- /dev/null +++ b/src/main/java/com/project/core/infra/repository/usage/UsageBatchFailureQueryRepositoryImpl.java @@ -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 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())); + } +} diff --git a/src/main/java/com/project/core/infra/repository/usage/UsageBatchJobQueryRepository.java b/src/main/java/com/project/core/infra/repository/usage/UsageBatchJobQueryRepository.java new file mode 100644 index 0000000..16a38f1 --- /dev/null +++ b/src/main/java/com/project/core/infra/repository/usage/UsageBatchJobQueryRepository.java @@ -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 findLatestJobSummaries(); +} diff --git a/src/main/java/com/project/core/infra/repository/usage/UsageBatchJobQueryRepositoryImpl.java b/src/main/java/com/project/core/infra/repository/usage/UsageBatchJobQueryRepositoryImpl.java new file mode 100644 index 0000000..3e4e770 --- /dev/null +++ b/src/main/java/com/project/core/infra/repository/usage/UsageBatchJobQueryRepositoryImpl.java @@ -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 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(); + }); + } +} diff --git a/src/main/java/com/project/core/service/UsageService.java b/src/main/java/com/project/core/service/UsageService.java new file mode 100644 index 0000000..67a693f --- /dev/null +++ b/src/main/java/com/project/core/service/UsageService.java @@ -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 jobs = usageBatchJobQueryRepository.findLatestJobSummaries(); + + // 2️⃣ Cloud Function 스케줄 정보 결합 + List 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(); + } + } +} From 57b1735543fc17bdb688dca892bbe80c06e5c763 Mon Sep 17 00:00:00 2001 From: yechan <100present@naver.com> Date: Mon, 26 Jan 2026 21:58:42 +0900 Subject: [PATCH 2/2] =?UTF-8?q?UPLUSE-121=20feat:=20sonacube=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index 252ed5e..7157f0e 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ def coverageExcludePackages = [ '**/notification/**', '**/usage/**', '**/util/**', + '**/core/**', ] // JaCoCo용 제외 패턴 (클래스 파일)