[INSPECTION]

- Ingest inspection raw data via opr-rest-api
This commit is contained in:
Hyojin Ahn 2025-12-23 14:54:32 -05:00
parent 777cb5bd13
commit 26d05d35f0
18 changed files with 675 additions and 5 deletions

View File

@ -41,10 +41,6 @@
</profiles>
<dependencies>
<!-- spring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@ -106,6 +102,10 @@
<version>${allure.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
</dependencies>
<build>

View File

@ -0,0 +1,11 @@
package com.goi.integration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.SpringApplication;
@SpringBootApplication
public class IntegrationServiceApplication {
public static void main(String[] args) {
SpringApplication.run(IntegrationServiceApplication.class, args);
}
}

View File

@ -0,0 +1,29 @@
package com.goi.integration.common.config;
import com.goi.integration.common.config.ScheduleJobConfigDto;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Optional;
@Component
public class InMemoryScheduleJobConfigProvider implements ScheduleJobConfigProvider {
private final Map<String, ScheduleJobConfigDto> configs = Map.of(
"SAMSARA_DVIR",
ScheduleJobConfigDto.builder()
.sjcJobCode("SAMSARA_DVIR")
.sjcEnabled(true)
.sjcCronExpression("0 */10 * * * *")
.sjcLookbackHours(24)
.sjcOverlapMinutes(10)
.sjcMaxRecords(252)
.sjcTimezone("UTC")
.build()
);
@Override
public Optional<ScheduleJobConfigDto> getJobConfig(String jobCode) {
return Optional.ofNullable(configs.get(jobCode));
}
}

View File

@ -0,0 +1,16 @@
package com.goi.integration.common.config;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
@Component
public class InternalWebClientFactory {
public WebClient create(String baseUrl, String token) {
return WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader("X-INTERNAL-SERVICE", "integration-service")
.defaultHeader("X-INTERNAL-TOKEN", token)
.build();
}
}

View File

@ -0,0 +1,20 @@
package com.goi.integration.common.config;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleJobConfigDto {
private String sjcJobCode; // SAMSARA_DVIR
private String sjcCronExpression; // "0 */10 * * * *"
private Integer sjcLookbackHours; // 24
private Integer sjcOverlapMinutes;// 10
private Integer sjcMaxRecords; // 252
private Boolean sjcEnabled; // true
private String sjcTimezone; // "UTC"
}

View File

@ -0,0 +1,10 @@
package com.goi.integration.common.config;
import com.goi.integration.common.config.ScheduleJobConfigDto;
import java.util.Optional;
public interface ScheduleJobConfigProvider {
Optional<ScheduleJobConfigDto> getJobConfig(String jobCode);
}

View File

@ -0,0 +1,30 @@
package com.goi.integration.common.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ExtIngestResult {
private String source;
private String recordType;
private int received;
private int inserted;
private int updated;
private int skipped;
public static ExtIngestResult empty(String source, String recordType) {
return ExtIngestResult.builder()
.source(source)
.recordType(recordType)
.received(0)
.inserted(0)
.updated(0)
.skipped(0)
.build();
}
}

View File

@ -0,0 +1,50 @@
package com.goi.integration.common.util;
import com.fasterxml.jackson.databind.JsonNode;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeParseException;
public final class DateTimeUtil {
private DateTimeUtil() {}
/**
* JsonNode LocalDateTime
* (ISO-8601, Z / offset 지원)
*/
public static LocalDateTime parse(JsonNode node) {
if (node == null || node.isMissingNode() || node.isNull()) {
return null;
}
return parse(node.asText(null));
}
/**
* String LocalDateTime
* ex) 2025-12-22T13:30:24.365Z
* ex) 2025-12-22T13:30:24+00:00
*/
public static LocalDateTime parse(String value) {
if (value == null || value.isBlank()) {
return null;
}
try {
return OffsetDateTime.parse(value).toLocalDateTime();
} catch (DateTimeParseException e) {
return null; // ingest 안정성 우선
}
}
public static LocalDateTime parseToToronto(String value) {
if (value == null || value.isBlank()) return null;
return OffsetDateTime
.parse(value)
.atZoneSameInstant(ZoneId.of("America/Toronto"))
.toLocalDateTime();
}
}

View File

@ -0,0 +1,80 @@
package com.goi.integration.common.util;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.TreeMap;
public final class ExtPayloadHashUtil {
private static final ObjectMapper mapper = new ObjectMapper();
private ExtPayloadHashUtil() {}
/** record(JsonNode) → canonical hash */
public static String sha256FromNode(JsonNode node) {
try {
JsonNode normalized = normalize(node);
String canonicalJson = mapper.writeValueAsString(normalized);
return sha256(canonicalJson);
} catch (Exception e) {
throw new RuntimeException("Failed to hash payload", e);
}
}
/** raw JSON string → hash (fallback 용) */
public static String sha256FromJson(String rawJson) {
try {
JsonNode node = mapper.readTree(rawJson);
return sha256FromNode(node);
} catch (Exception e) {
throw new RuntimeException("Failed to hash raw json", e);
}
}
/* ---------- internal ---------- */
@SuppressWarnings({ "serial", "deprecation" })
private static JsonNode normalize(JsonNode node) {
if (node.isObject()) {
ObjectNode obj = mapper.createObjectNode();
new TreeMap<String, JsonNode>() {{
node.fields().forEachRemaining(e -> put(e.getKey(), normalize(e.getValue())));
}}.forEach(obj::set);
return obj;
}
if (node.isArray()) {
ArrayNode arr = mapper.createArrayNode();
node.forEach(n -> arr.add(normalize(n)));
return arr;
}
return node;
}
private static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : hash) hex.append(String.format("%02x", b));
return hex.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static String sha256FromJsonNode(JsonNode node) {
try {
JsonNode normalized = normalize(node);
String canonicalJson = mapper.writeValueAsString(normalized);
return sha256(canonicalJson);
} catch (Exception e) {
throw new RuntimeException("Failed to hash JsonNode payload", e);
}
}
}

View File

@ -0,0 +1,102 @@
package com.goi.integration.samsara.client;
import com.goi.integration.common.dto.ExtIngestResult;
import com.goi.integration.samsara.dto.ExtSamsaraInspectionIngestCommand;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
@Slf4j
@Component
@RequiredArgsConstructor
public class OprIngestClient {
private static final String INTERNAL_TOKEN_HEADER = "X-INTERNAL-TOKEN";
@Qualifier("oprWebClient")
private final WebClient oprWebClient;
@Value("${ext.opr.internal-token}")
private String token;
// @Autowired
// private ObjectMapper objectMapper;
public ExtIngestResult ingestInspection(ExtSamsaraInspectionIngestCommand command) {
// String json = objectMapper
// .writerWithDefaultPrettyPrinter()
// .writeValueAsString(command);
//
log.info(
"[OPR_INGEST][REQUEST] source={}, type={}, records={}, fetchedAt={}",
command.getSource(),
command.getRecordType(),
command.getRecords() != null ? command.getRecords().size() : 0,
command.getFetchedAt()
);
/*
try {
log.debug(
"[OPR_INGEST][REQUEST_BODY]\n{}",
new com.fasterxml.jackson.databind.ObjectMapper()
.writerWithDefaultPrettyPrinter()
.writeValueAsString(command)
);
} catch (Exception e) {
log.debug("[OPR_INGEST][REQUEST_BODY] failed to serialize", e);
}
*/
try {
ExtIngestResult result = oprWebClient.post()
.uri("/ext/samsara/inspections/ingest")
.header(INTERNAL_TOKEN_HEADER, token)
.bodyValue(command)
.retrieve()
.onStatus(
status -> status.is4xxClientError() || status.is5xxServerError(),
resp -> resp.bodyToMono(String.class)
.map(body -> {
if (resp.statusCode().value() == 403) {
return new IllegalStateException(
"OPR_AUTH_FAILED: " + body
);
}
return new RuntimeException(
"OPR_INGEST_HTTP_ERROR: " + resp.statusCode() + " body=" + body
);
})
)
.bodyToMono(ExtIngestResult.class)
.block();
// 성공 로그
log.info(
"[OPR_INGEST][SUCCESS] received={}, inserted={}, updated={}, skipped={}",
result.getReceived(),
result.getInserted(),
result.getUpdated(),
result.getSkipped()
);
return result;
} catch (Exception e) {
// 실패 로그
log.error(
"[OPR_INGEST][FAILED] source={}, type={}, error={}",
command.getSource(),
command.getRecordType(),
e.getMessage(),
e
);
throw e;
}
}
}

View File

@ -0,0 +1,41 @@
package com.goi.integration.samsara.client;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Instant;
@Component
public class SamsaraClient {
private final WebClient webClient;
public SamsaraClient(
@Value("${ext.samsara.base-url:https://api.samsara.com}") String baseUrl,
@Value("${ext.samsara.api-token}") String apiToken
) {
this.webClient = WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiToken)
.build();
}
/**
* DVIR history raw JSON (string) 받아도 되고, DTO로 매핑해도 .
* 일단 초기엔 String으로 받아서 JsonNode로 파싱하는게 가장 유연함.
*/
public String getDvirHistory(int limit, Instant startTime, Instant endTime) {
return webClient.get()
.uri(uriBuilder -> uriBuilder
.path("/fleet/dvirs/history")
.queryParam("limit", limit)
.queryParam("startTime", startTime.toString())
.queryParam("endTime", endTime.toString())
.build())
.retrieve()
.bodyToMono(String.class)
.block();
}
}

View File

@ -0,0 +1,20 @@
package com.goi.integration.samsara.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
@Configuration
public class OprClientConfig {
@Bean
public WebClient oprWebClient(
WebClient.Builder builder,
@Value("${ext.opr.base-url}") String baseUrl
) {
return builder
.baseUrl(baseUrl)
.build();
}
}

View File

@ -0,0 +1,19 @@
package com.goi.integration.samsara.controller;
import com.goi.integration.samsara.job.SamsaraDvirIngestJob;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/internal/test")
@RequiredArgsConstructor
public class SamsaraTestController {
private final SamsaraDvirIngestJob dvirJob;
@PostMapping("/samsara-dvir")
public String runDvirNow() {
dvirJob.run();
return "SAMSARA_DVIR_TRIGGERED";
}
}

View File

@ -0,0 +1,20 @@
package com.goi.integration.samsara.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ExtSamsaraInspectionIngestCommand {
private String source;
private String recordType;
private LocalDateTime fetchedAt;
private List<ExtSamsaraInspectionRecordDto> records;
}

View File

@ -0,0 +1,26 @@
package com.goi.integration.samsara.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import com.fasterxml.jackson.databind.JsonNode;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ExtSamsaraInspectionRecordDto {
private String externalId; // inspection id
private String vehicleExternalId; // vehicle.id
private String driverExternalId; // signatoryUser.id
private String inspectionType; // preTrip / postTrip
private LocalDateTime startTime;
private LocalDateTime endTime;
private LocalDateTime signedAt;
private String payloadHash; // SHA-256
private JsonNode payloadJson; // raw inspection JSON
}

View File

@ -0,0 +1,59 @@
package com.goi.integration.samsara.job;
import com.goi.integration.common.config.ScheduleJobConfigDto;
import com.goi.integration.common.config.ScheduleJobConfigProvider;
import com.goi.integration.common.dto.ExtIngestResult;
import com.goi.integration.samsara.client.SamsaraClient;
import com.goi.integration.samsara.service.SamsaraInspectionIngestService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.Instant;
@Slf4j
@Component
@RequiredArgsConstructor
public class SamsaraDvirIngestJob {
private static final String JOB_CODE = "SAMSARA_DVIR";
private final ScheduleJobConfigProvider configProvider;
private final SamsaraClient samsaraClient;
private final SamsaraInspectionIngestService ingestService;
@Scheduled(cron = "${ext.samsara.jobs.dvir.cron:0 */10 * * * *}")
public void run() {
ScheduleJobConfigDto cfg = configProvider.getJobConfig(JOB_CODE)
.filter(ScheduleJobConfigDto::getSjcEnabled)
.orElse(null);
if (cfg == null) {
log.info("[{}] disabled or config missing", JOB_CODE);
return;
}
int limit = (cfg.getSjcMaxRecords() != null) ? cfg.getSjcMaxRecords() : 252;
Instant now = Instant.now();
long lookbackSec = Duration.ofHours(cfg.getSjcLookbackHours() != null ? cfg.getSjcLookbackHours() : 24).getSeconds();
long overlapSec = Duration.ofMinutes(cfg.getSjcOverlapMinutes() != null ? cfg.getSjcOverlapMinutes() : 0).getSeconds();
Instant start = now.minusSeconds(lookbackSec + overlapSec);
Instant end = now;
log.info("[{}] calling samsara dvir history start={} end={} limit={}", JOB_CODE, start, end, limit);
String rawJson = samsaraClient.getDvirHistory(limit, start, end);
// opr-rest-api ingest 호출
ExtIngestResult result = ingestService.ingestFromRawJson(rawJson);
log.info("[{}] ingest result received={}, inserted={}, updated={}, skipped={}",
JOB_CODE, result.getReceived(), result.getInserted(), result.getUpdated(), result.getSkipped());
}
}

View File

@ -0,0 +1,103 @@
package com.goi.integration.samsara.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.goi.integration.common.dto.ExtIngestResult;
import com.goi.integration.common.util.DateTimeUtil;
import com.goi.integration.common.util.ExtPayloadHashUtil;
import com.goi.integration.samsara.client.OprIngestClient;
import com.goi.integration.samsara.dto.ExtSamsaraInspectionIngestCommand;
import com.goi.integration.samsara.dto.ExtSamsaraInspectionRecordDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class SamsaraInspectionIngestService {
private static final String JOB_CODE = "SAMSARA_DVIR";
private final ObjectMapper objectMapper;
private final OprIngestClient oprIngestClient;
/**
* Samsara DVIR raw JSON 응답을 받아
* 1) record 단위로 분해
* 2) hash 생성
* 3) opr-rest-api ingest endpoint 호출
*/
public ExtIngestResult ingestFromRawJson(String rawJson) {
try {
// 전체 JSON 파싱
JsonNode root = objectMapper.readTree(rawJson);
JsonNode data = root.path("data");
// data[] 없음
if (!data.isArray()) {
log.warn("Samsara DVIR response has no data[] array");
return ExtIngestResult.empty("SAMSARA", "DVIR");
}
//
List<ExtSamsaraInspectionRecordDto> records = new ArrayList<>();
log.info("[{}] data={}", JOB_CODE, data.size());
// data[] node 하나의 inspection (preTrip / postTrip)
for (JsonNode node : data) {
// parsing
String externalId = node.path("id").asText(null);
String vehicleExtId = node.path("vehicle").path("id").asText(null);
String driverExtId = node.path("authorSignature")
.path("signatoryUser")
.path("id")
.asText(null);
String inspectionType = node.path("type").asText(null);
LocalDateTime startTime = DateTimeUtil.parseToToronto(node.path("startTime").asText(null));
LocalDateTime endTime = DateTimeUtil.parseToToronto(node.path("endTime").asText(null));
LocalDateTime signedAt = DateTimeUtil.parseToToronto(
node.path("authorSignature").path("signedAtTime").asText(null)
);
// record 단위 hash 생성 (opr-rest-api 에서 idempotent ingest 판단용)
String hash = ExtPayloadHashUtil.sha256FromJsonNode(node);
// record DTO
records.add(
ExtSamsaraInspectionRecordDto.builder()
.externalId(externalId)
.vehicleExternalId(vehicleExtId)
.driverExternalId(driverExtId)
.inspectionType(inspectionType)
.startTime(startTime)
.endTime(endTime)
.signedAt(signedAt)
.payloadJson(node) // 원본 payload (JSON 그대로 저장)
.payloadHash(hash)
.build()
);
}
// ingest command 생성
ExtSamsaraInspectionIngestCommand command =
ExtSamsaraInspectionIngestCommand.builder()
.source("SAMSARA")
.recordType("DVIR")
.fetchedAt(LocalDateTime.now())
.records(records)
.build();
// opr-rest-api ingest endpoint 호출
return oprIngestClient.ingestInspection(command);
} catch (Exception e) {
log.error("Failed to ingest samsara DVIR payload", e);
throw new RuntimeException(e);
}
}
}

View File

@ -14,5 +14,39 @@ management:
exposure:
include: health
server:
port: 8091
servlet:
context-path: /integration-service
context-path: /integration-service
# ============================
# External Integrations
# ============================
ext:
opr:
base-url: http://localhost:8083/opr-rest-api # opr-rest-api 주소
ingest-path: /ext/samsara/inspections/ingest
internal-token: ${OPR_INTERNAL_TOKEN}
hcm:
base-url: http://localhost:8081/hcm-rest-api
internal-token: ${HCM_INTERNAL_TOKEN}
acc:
base-url: http://localhost:8084/acc-rest-api
internal-token: ${ACC_INTERNAL_TOKEN}
samsara:
base-url: https://api.samsara.com
api-token: ${SAMSARA_API_TOKEN} # 반드시 env 로
timeout:
connect-ms: 5000
read-ms: 10000
jobs:
dvir:
cron: "0 */10 * * * *" # 10분
# ============================
# logging
# ============================
logging:
file:
name: logs/integration-service.log
level:
root: INFO
com.goi.integration: INFO