diff --git a/build.gradle b/build.gradle index 28e24ef..252ed5e 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,8 @@ def coverageExcludePackages = [ '**/batch/**', // Batch 제외 '**/invoice/**', '**/notification/**', + '**/usage/**', + '**/util/**', ] // JaCoCo용 제외 패턴 (클래스 파일) @@ -96,6 +98,9 @@ dependencies { // RestTemplate implementation "org.apache.httpcomponents.client5:httpclient5" + // Cron + implementation "com.cronutils:cron-utils:9.2.1" + } // ============================================================================ diff --git a/src/main/java/com/project/core/controller/InvoiceBatchAdminController.java b/src/main/java/com/project/core/controller/InvoiceBatchAdminController.java index 7b236af..e180c48 100644 --- a/src/main/java/com/project/core/controller/InvoiceBatchAdminController.java +++ b/src/main/java/com/project/core/controller/InvoiceBatchAdminController.java @@ -9,28 +9,28 @@ import com.project.core.controller.dto.request.BatchTriggerRequest; import com.project.core.controller.dto.request.UpdateBatchScheduleRequest; -import com.project.core.service.BatchScheduleService; import com.project.core.util.BatchTriggerClient; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @RestController @RequestMapping("/admin/batch/invoice") @RequiredArgsConstructor +@Slf4j public class InvoiceBatchAdminController { private final BatchTriggerClient batchTriggerClient; - private final BatchScheduleService scheduleService; @PostMapping("/run-now") public ResponseEntity runNow(@RequestBody BatchTriggerRequest request) { + log.info("[run-now] called. job={}, invMonth={}", request.job(), request.invMonth()); batchTriggerClient.trigger(request.job(), request.invMonth()); return ResponseEntity.accepted().build(); } @PutMapping("/schedule") public ResponseEntity updateSchedule(@RequestBody UpdateBatchScheduleRequest request) { - scheduleService.updateSchedule(request.job(), request.cron()); batchTriggerClient.schedule(request.job(), request.cron()); return ResponseEntity.ok().build(); } diff --git a/src/main/java/com/project/core/controller/dto/BatchSummaryDto.java b/src/main/java/com/project/core/controller/dto/BatchSummaryDto.java index 0b82925..e1df75f 100644 --- a/src/main/java/com/project/core/controller/dto/BatchSummaryDto.java +++ b/src/main/java/com/project/core/controller/dto/BatchSummaryDto.java @@ -3,15 +3,24 @@ import java.time.LocalDateTime; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Getter +@Builder(toBuilder = true) +@NoArgsConstructor @AllArgsConstructor public class BatchSummaryDto { private String jobName; private String status; private String exitCode; private String exitMessage; + + private String cronExpression; // 0 0 2 * * * + private String cronDescription; // 매일 오전 2시 + private LocalDateTime nextFireTime; // 다음 실행 시각 + private LocalDateTime startTime; private LocalDateTime endTime; private long durationMs; diff --git a/src/main/java/com/project/core/controller/dto/response/BatchScheduleResponse.java b/src/main/java/com/project/core/controller/dto/response/BatchScheduleResponse.java new file mode 100644 index 0000000..24901d7 --- /dev/null +++ b/src/main/java/com/project/core/controller/dto/response/BatchScheduleResponse.java @@ -0,0 +1,21 @@ +package com.project.core.controller.dto.response; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class BatchScheduleResponse { + + private String jobType; // INVOICE + private String scheduleName; // projects/.../jobs/... + private String cron; // 15 12 26 1 * + private String invMonth; // null 가능 + private String description; // Auto-created/Updated via Batch Control API + private String state; // ENABLED / DISABLED + private String timeZone; // Asia/Seoul + + public boolean isEnabled() { + return "ENABLED".equalsIgnoreCase(state); + } +} 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 7db9f0e..0a79481 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 @@ -8,8 +8,8 @@ @Getter @RequiredArgsConstructor public enum BatchJobType { - INVOICE_ITEM_JOB("invoiceItemJob", "청구 내역 생성"), - INVOICE_JOB("invoiceJob", "청구서 생성"); + INVOICE_ITEM_JOB("invoiceItemJob", "INVOICE_ITEM"), + INVOICE_JOB("invoiceJob", "INVOICE"); private final String jobName; private final String title; diff --git a/src/main/java/com/project/core/infra/repository/invoice/BatchFailureQueryRepositoryImpl.java b/src/main/java/com/project/core/infra/repository/invoice/BatchFailureQueryRepositoryImpl.java index b03928b..7eb8c46 100644 --- a/src/main/java/com/project/core/infra/repository/invoice/BatchFailureQueryRepositoryImpl.java +++ b/src/main/java/com/project/core/infra/repository/invoice/BatchFailureQueryRepositoryImpl.java @@ -30,6 +30,7 @@ public List findRecentFailures(int limit) { JOIN BATCH_JOB_EXECUTION je ON ji.JOB_INSTANCE_ID = je.JOB_INSTANCE_ID WHERE je.STATUS = 'FAILED' + AND ji.JOB_NAME IN ('invoiceJob', 'invoiceItemJob') ORDER BY je.END_TIME DESC LIMIT ? """; diff --git a/src/main/java/com/project/core/infra/repository/invoice/BatchJobQueryRepositoryImpl.java b/src/main/java/com/project/core/infra/repository/invoice/BatchJobQueryRepositoryImpl.java index e1461ff..323a395 100644 --- a/src/main/java/com/project/core/infra/repository/invoice/BatchJobQueryRepositoryImpl.java +++ b/src/main/java/com/project/core/infra/repository/invoice/BatchJobQueryRepositoryImpl.java @@ -1,5 +1,6 @@ package com.project.core.infra.repository.invoice; +import java.time.LocalDateTime; import java.util.List; import org.springframework.jdbc.core.JdbcTemplate; @@ -46,16 +47,25 @@ WHERE ji2.JOB_NAME IN ('invoiceJob', 'invoiceItemJob') return jdbcTemplate.query( sql, (rs, rowNum) -> { - var startTimeStamp = rs.getTimestamp("START_TIME"); - var endTimeStamp = rs.getTimestamp("END_TIME"); - return new BatchSummaryDto( - rs.getString("JOB_NAME"), - rs.getString("STATUS"), - rs.getString("EXIT_CODE"), - rs.getString("EXIT_MESSAGE"), - startTimeStamp != null ? startTimeStamp.toLocalDateTime() : null, - endTimeStamp != null ? endTimeStamp.toLocalDateTime() : null, - rs.getLong("DURATION_MS")); + 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/infra/repository/invoice/BatchScheduleRepository.java b/src/main/java/com/project/core/infra/repository/invoice/BatchScheduleRepository.java deleted file mode 100644 index c580178..0000000 --- a/src/main/java/com/project/core/infra/repository/invoice/BatchScheduleRepository.java +++ /dev/null @@ -1,28 +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 BatchScheduleRepository { - - private final JdbcTemplate jdbcTemplate; - - public void upsert(String jobName, String cron) { - - String sql = - """ - INSERT INTO batch_schedule (job_name, cron_expression, updated_at) - VALUES (?, ?, now()) - ON CONFLICT (job_name) - DO UPDATE SET - cron_expression = EXCLUDED.cron_expression, - updated_at = now() - """; - - jdbcTemplate.update(sql, jobName, cron); - } -} diff --git a/src/main/java/com/project/core/service/BatchScheduleService.java b/src/main/java/com/project/core/service/BatchScheduleService.java deleted file mode 100644 index 78b1d6c..0000000 --- a/src/main/java/com/project/core/service/BatchScheduleService.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.project.core.service; - -import org.springframework.stereotype.Service; - -import com.project.core.infra.repository.invoice.BatchScheduleRepository; -import com.project.global.exception.code.domain.core.CoreErrorCode; -import com.project.global.exception.core.OperationFailedException; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -@Slf4j -public class BatchScheduleService { - - private final BatchScheduleRepository repository; - - public void updateSchedule(String jobName, String cron) { - - try { - repository.upsert(jobName, cron); - } catch (Exception e) { - log.error("[BatchSchedule] update failed. jobName={}, cron={}", jobName, cron, e); - - throw new OperationFailedException(CoreErrorCode.BATCH_SCHEDULE_UPDATE_FAILED); - } - } -} diff --git a/src/main/java/com/project/core/service/InvoiceService.java b/src/main/java/com/project/core/service/InvoiceService.java index 1f124ff..3c5ea86 100644 --- a/src/main/java/com/project/core/service/InvoiceService.java +++ b/src/main/java/com/project/core/service/InvoiceService.java @@ -1,11 +1,25 @@ 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.BillingDashboardResponse; import com.project.core.infra.repository.invoice.BatchFailureQueryRepository; import com.project.core.infra.repository.invoice.BatchJobQueryRepository; +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; @@ -20,13 +34,21 @@ public class InvoiceService { private final BatchJobQueryRepository batchJobQueryRepository; private final BatchFailureQueryRepository batchFailureQueryRepository; + private final BatchScheduleClient batchScheduleClient; private final BillingProperties billingProperties; public BillingDashboardResponse getDashboard() { try { + // 1️⃣ 최신 배치 실행 요약 조회 + List jobs = batchJobQueryRepository.findLatestJobSummaries(); + + // 2️⃣ Cloud Function 스케줄 정보 결합 + List enrichedJobs = + jobs.stream().map(this::enrichWithSchedule).toList(); + return BillingDashboardResponse.builder() - .batchJobs(batchJobQueryRepository.findLatestJobSummaries()) + .batchJobs(enrichedJobs) .grafana( GrafanaInfoDto.builder() .iframeUrl(billingProperties.getIframeUrl()) @@ -36,8 +58,62 @@ public BillingDashboardResponse getDashboard() { .build(); } catch (Exception e) { - log.error("[BillingDashboard] 대시보드 빌딩에 실패했습니다.", e); + log.error("[BillingDashboard] 대시보드 빌딩 실패", 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("[BillingDashboard] cron 처리 실패 job={}, cron={}", job.getJobName(), cronStr, e); + + return job.toBuilder().cronExpression(cronStr).cronDescription("잘못된 크론식").build(); + } + } } diff --git a/src/main/java/com/project/core/util/BatchScheduleClient.java b/src/main/java/com/project/core/util/BatchScheduleClient.java new file mode 100644 index 0000000..f67ec1e --- /dev/null +++ b/src/main/java/com/project/core/util/BatchScheduleClient.java @@ -0,0 +1,49 @@ +package com.project.core.util; + +import java.util.Optional; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import com.project.core.controller.dto.response.BatchScheduleResponse; +import com.project.core.infra.entity.batch.BatchJobType; +import com.project.global.config.CloudFunctionProperties; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class BatchScheduleClient { + + private final RestTemplate restTemplate; + private final CloudFunctionProperties properties; + + public Optional getSchedule(String jobName) { + + String title = BatchJobType.getTitleByJobName(jobName); + String url = properties.getBaseUrl() + "/schedule?job=" + title; + + try { + ResponseEntity response = + restTemplate.getForEntity(url, BatchScheduleResponse.class); + + return Optional.ofNullable(response.getBody()); + + } catch (HttpStatusCodeException e) { + log.warn( + "[BatchSchedule] 조회 실패 job={}, status={}, body={}", + jobName, + e.getStatusCode(), + e.getResponseBodyAsString()); + return Optional.empty(); + + } catch (Exception e) { + log.error("[BatchSchedule] 조회 중 오류 job={}", jobName, e); + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/project/core/util/BatchTriggerClient.java b/src/main/java/com/project/core/util/BatchTriggerClient.java index b03c61a..c2f8caf 100644 --- a/src/main/java/com/project/core/util/BatchTriggerClient.java +++ b/src/main/java/com/project/core/util/BatchTriggerClient.java @@ -28,6 +28,7 @@ public class BatchTriggerClient { public void trigger(String job, String invMonth) { String url = properties.getBaseUrl() + "/run-now"; + log.info("[BatchTrigger] calling url={}", url); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/src/main/java/com/project/global/config/CloudFunctionProperties.java b/src/main/java/com/project/global/config/CloudFunctionProperties.java index 2370897..fb9f9f2 100644 --- a/src/main/java/com/project/global/config/CloudFunctionProperties.java +++ b/src/main/java/com/project/global/config/CloudFunctionProperties.java @@ -1,13 +1,15 @@ package com.project.global.config; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; -import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.Setter; -@Getter -@AllArgsConstructor +@Component @ConfigurationProperties(prefix = "cloud.function") +@Getter +@Setter public class CloudFunctionProperties { /** Cloud Function base URL 예: https://REGION-PROJECT.cloudfunctions.net */ diff --git a/src/main/java/com/project/global/config/RestTemplateConfig.java b/src/main/java/com/project/global/config/RestTemplateConfig.java index a4b5621..31fa717 100644 --- a/src/main/java/com/project/global/config/RestTemplateConfig.java +++ b/src/main/java/com/project/global/config/RestTemplateConfig.java @@ -19,20 +19,22 @@ public class RestTemplateConfig { @Bean public RestTemplate restTemplate(RestTemplateBuilder builder) { - // 1️⃣ TCP 연결 타임아웃 + // 1️⃣ Connection-level 설정 (TCP 연결) ConnectionConfig connectionConfig = ConnectionConfig.custom().setConnectTimeout(Timeout.ofSeconds(5)).build(); - // 2️⃣ 요청/응답 타임아웃 - RequestConfig requestConfig = - RequestConfig.custom() - .setResponseTimeout(Timeout.ofSeconds(5)) - .setConnectionRequestTimeout(Timeout.ofSeconds(5)) - .build(); - PoolingHttpClientConnectionManager cm = PoolingHttpClientConnectionManagerBuilder.create() .setDefaultConnectionConfig(connectionConfig) + .setMaxConnTotal(100) + .setMaxConnPerRoute(20) + .build(); + + // 2️⃣ Request-level 설정 (요청/응답) + RequestConfig requestConfig = + RequestConfig.custom() + .setResponseTimeout(Timeout.ofSeconds(30)) // 응답 대기 + .setConnectionRequestTimeout(Timeout.ofSeconds(5)) // 풀 대기 .build(); CloseableHttpClient httpClient = diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 41b0d88..7be2722 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -85,9 +85,4 @@ logging: org.springframework.web: INFO org.hibernate.SQL: WARN pattern: - level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" - -billing: - grafana: - iframe-url: https://grafana.example.com/d/xxx - refresh-interval: 5s + level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" \ No newline at end of file diff --git a/src/test/java/com/project/core/controller/InvoiceBatchAdminControllerTest.java b/src/test/java/com/project/core/controller/InvoiceBatchAdminControllerTest.java index af75193..e7b7878 100644 --- a/src/test/java/com/project/core/controller/InvoiceBatchAdminControllerTest.java +++ b/src/test/java/com/project/core/controller/InvoiceBatchAdminControllerTest.java @@ -12,7 +12,6 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import com.project.core.service.BatchScheduleService; import com.project.core.util.BatchTriggerClient; @WebMvcTest(InvoiceBatchAdminController.class) @@ -22,8 +21,6 @@ class InvoiceBatchAdminControllerTest { @MockitoBean private BatchTriggerClient batchTriggerClient; - @MockitoBean private BatchScheduleService scheduleService; - @Test @DisplayName("스케줄 수정 트리거 성공") void updateSchedule_should_update_db_and_trigger_cf() throws Exception { @@ -44,7 +41,6 @@ void updateSchedule_should_update_db_and_trigger_cf() throws Exception { .andExpect(status().isOk()); // then (행위 검증) - verify(scheduleService).updateSchedule("invoiceJob", "0 0 2 * * ?"); verify(batchTriggerClient).schedule("invoiceJob", "0 0 2 * * ?"); } @@ -55,11 +51,11 @@ void runNow_should_trigger_batch() throws Exception { String body = """ - { - "job": "invoiceJob", - "invMonth": "202601" - } - """; + { + "job": "invoiceJob", + "invMonth": "202601" + } + """; mockMvc.perform( post("/admin/batch/invoice/run-now").contentType("application/json").content(body)); diff --git a/src/test/java/com/project/core/service/InvoiceServiceTest.java b/src/test/java/com/project/core/service/InvoiceServiceTest.java deleted file mode 100644 index 452628f..0000000 --- a/src/test/java/com/project/core/service/InvoiceServiceTest.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.project.core.service; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; - -import java.time.LocalDateTime; -import java.util.List; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.project.core.controller.dto.BatchSummaryDto; -import com.project.core.controller.dto.response.BillingDashboardResponse; -import com.project.core.infra.repository.invoice.BatchFailureQueryRepository; -import com.project.core.infra.repository.invoice.BatchJobQueryRepository; -import com.project.global.config.BillingProperties; -import com.project.global.exception.code.domain.core.CoreErrorCode; -import com.project.global.exception.core.EntityNotFoundException; - -@ExtendWith(MockitoExtension.class) -class InvoiceServiceTest { - - @Mock private BatchJobQueryRepository batchJobQueryRepository; - - @Mock private BatchFailureQueryRepository batchFailureQueryRepository; - - @Mock private BillingProperties billingProperties; - - @InjectMocks private InvoiceService invoiceService; - - @Test - @DisplayName("[조회] 성공 - 배치 대시보드 조회") - void getDashboard_success() { - // given - given(batchJobQueryRepository.findLatestJobSummaries()) - .willReturn( - List.of( - new BatchSummaryDto( - "invoiceJob", - "COMPLETED", - "COMPLETED", - null, - LocalDateTime.now().minusSeconds(5), - LocalDateTime.now(), - 5000L))); - - given(batchFailureQueryRepository.findRecentFailures(10)).willReturn(List.of()); - - given(billingProperties.getIframeUrl()).willReturn("https://grafana.example.com"); - - given(billingProperties.getRefreshInterval()).willReturn("5s"); - - // when - BillingDashboardResponse response = invoiceService.getDashboard(); - - // then - assertThat(response).isNotNull(); - assertThat(response.getBatchJobs()).hasSize(1); - assertThat(response.getGrafana().getIframeUrl()).isEqualTo("https://grafana.example.com"); - assertThat(response.getRecentFailures()).isEmpty(); - } - - @Test - @DisplayName("[조회] 실패 - 배치 대시보드 없음") - void getDashboard_repositoryFailure_shouldThrowBillingDashboardException() { - // given - given(batchJobQueryRepository.findLatestJobSummaries()) - .willThrow(new RuntimeException("DB error")); - - // when & then - assertThatThrownBy(() -> invoiceService.getDashboard()) - .isInstanceOf(EntityNotFoundException.class) - .extracting("code") - .isEqualTo(CoreErrorCode.DASHBOARD_NOT_FOUND); - } -}