diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java index cfe4225..ccb0651 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/token/BearerTokenRecord.java @@ -1,6 +1,6 @@ package com.cloudhandson.vpdbackoffice.domain.token; -import java.time.OffsetDateTime; +import java.time.LocalDateTime; public record BearerTokenRecord( long keyId, @@ -8,12 +8,12 @@ public record BearerTokenRecord( String username, String keyPrefix, String keyHash, - OffsetDateTime expiresAt, - OffsetDateTime revokedAt, + LocalDateTime expiresAt, + LocalDateTime revokedAt, String description ) { - public boolean active(OffsetDateTime now) { + public boolean active(LocalDateTime now) { return revokedAt == null && expiresAt != null && expiresAt.isAfter(now); } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java index d80923b..6eddabe 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/mapper/BearerTokenMapper.java @@ -1,7 +1,7 @@ package com.cloudhandson.vpdbackoffice.mapper; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; -import java.time.OffsetDateTime; +import java.time.LocalDateTime; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -19,7 +19,7 @@ public interface BearerTokenMapper { int revokeToken( @Param("keyId") long keyId, - @Param("revokedAt") OffsetDateTime revokedAt, + @Param("revokedAt") LocalDateTime revokedAt, @Param("reason") String reason ); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java index 35c6738..8542fd2 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/BearerTokenService.java @@ -9,7 +9,9 @@ import com.cloudhandson.vpdbackoffice.domain.user.AppUser; import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper; import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import java.time.Clock; +import java.time.LocalDateTime; import java.time.OffsetDateTime; +import java.time.ZoneId; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -81,6 +83,9 @@ public class BearerTokenService { String prefix = tokenGenerator.prefix(plainToken); String hash = tokenHasher.sha256(plainToken); long keyId = tokenMapper.nextKeyId(); + LocalDateTime expiresAt = command.expiresAt() + .atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime(); tokenMapper.insertToken(new BearerTokenRecord( keyId, @@ -88,7 +93,7 @@ public class BearerTokenService { user.username(), prefix, hash, - command.expiresAt(), + expiresAt, null, command.description() )); @@ -98,7 +103,11 @@ public class BearerTokenService { @Transactional public void revokeToken(long keyId, String reason) { - int updated = tokenMapper.revokeToken(keyId, OffsetDateTime.now(clock), reason); + int updated = tokenMapper.revokeToken( + keyId, + LocalDateTime.now(clock.withZone(ZoneId.systemDefault())), + reason + ); if (updated == 0) { throw new AppException("회수할 활성 토큰을 찾을 수 없습니다."); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java index 0b8516a..04ad755 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/OrdsProbeService.java @@ -13,7 +13,8 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.URI; import java.time.Clock; -import java.time.OffsetDateTime; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @@ -71,7 +72,7 @@ public class OrdsProbeService { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다.")); } - if (!token.active(OffsetDateTime.now(clock))) { + if (!token.active(LocalDateTime.now(clock.withZone(ZoneId.systemDefault())))) { return auditAndReturn(command, ProbeResult.blocked( ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다.")); } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java index 5910ad8..d205681 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/AppExceptionHandler.java @@ -17,13 +17,17 @@ public class AppExceptionHandler { @ExceptionHandler(DataAccessException.class) public String handleDataAccessException(DataAccessException exception, Model model) { - String detail = exception.getMostSpecificCause() == null - ? exception.getMessage() - : exception.getMostSpecificCause().getMessage(); - model.addAttribute("errorTitle", "ADB 연결 설정이 필요합니다."); - model.addAttribute("errorMessage", - "BACKOFFICE_DB_URL, BACKOFFICE_DB_USERNAME, BACKOFFICE_DB_PASSWORD를 실제 ADB 값으로 설정하고 " - + "./run.sh backoffice-support를 실행한 뒤 다시 시도하세요. 상세: " + detail); + RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception); + model.addAttribute("errorTitle", error.title()); + model.addAttribute("errorMessage", error.message()); + return "error"; + } + + @ExceptionHandler(Exception.class) + public String handleUnexpectedException(Exception exception, Model model) { + RuntimeErrorMessage error = RuntimeErrorMessages.unexpected(exception); + model.addAttribute("errorTitle", error.title()); + model.addAttribute("errorMessage", error.message()); return "error"; } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java index 496b3f5..be1000b 100644 --- a/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/DashboardController.java @@ -33,18 +33,14 @@ public class DashboardController { model.addAttribute("roles", permissionService.findRoles()); model.addAttribute("tokens", bearerTokenService.findAll()); } catch (DataAccessException e) { + RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(e); model.addAttribute("objects", List.of()); model.addAttribute("roles", List.of()); model.addAttribute("tokens", List.of()); - model.addAttribute("setupError", dbSetupMessage(e)); + model.addAttribute("runtimeErrorTitle", error.title()); + model.addAttribute("runtimeErrorMessage", error.message()); + model.addAttribute("showSupportCommand", error.showSupportCommand()); } return "dashboard"; } - - private String dbSetupMessage(DataAccessException e) { - String detail = e.getMostSpecificCause() == null ? e.getMessage() : e.getMostSpecificCause().getMessage(); - return "ADB 연결을 확인할 수 없습니다. BACKOFFICE_DB_URL, BACKOFFICE_DB_USERNAME, " - + "BACKOFFICE_DB_PASSWORD를 설정하고 ./run.sh backoffice-support를 먼저 실행하세요. 상세: " - + detail; - } } diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java new file mode 100644 index 0000000..8faea03 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessage.java @@ -0,0 +1,8 @@ +package com.cloudhandson.vpdbackoffice.web; + +public record RuntimeErrorMessage( + String title, + String message, + boolean showSupportCommand +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java new file mode 100644 index 0000000..39d3742 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/RuntimeErrorMessages.java @@ -0,0 +1,71 @@ +package com.cloudhandson.vpdbackoffice.web; + +import java.sql.SQLException; +import java.util.Locale; +import org.springframework.dao.DataAccessException; + +final class RuntimeErrorMessages { + + private RuntimeErrorMessages() { + } + + static RuntimeErrorMessage dataAccess(DataAccessException exception) { + String detail = detail(exception); + if (looksLikeConnectionProblem(exception, detail)) { + return new RuntimeErrorMessage( + "DB 연결 설정이 필요합니다.", + "ADB에 연결할 수 없습니다. BACKOFFICE_DB_URL, BACKOFFICE_DB_USERNAME, " + + "BACKOFFICE_DB_PASSWORD를 확인하고 ./run.sh backoffice-support를 다시 실행하세요. 상세: " + + detail, + true + ); + } + return new RuntimeErrorMessage( + "데이터 처리 오류가 발생했습니다.", + "요청을 처리하는 중 DB 데이터 타입, SQL, 또는 매핑 오류가 발생했습니다. " + + "입력값과 백오피스 테이블 스키마를 확인하세요. 상세: " + detail, + false + ); + } + + static RuntimeErrorMessage unexpected(Exception exception) { + return new RuntimeErrorMessage( + "요청 처리 중 오류가 발생했습니다.", + "예상하지 못한 오류가 발생했습니다. 입력값을 확인한 뒤 다시 시도하세요. 상세: " + + safeMessage(exception), + false + ); + } + + private static boolean looksLikeConnectionProblem(DataAccessException exception, String detail) { + String text = (exception.getMessage() + " " + detail).toLowerCase(Locale.ROOT); + return text.contains("connection refused") + || text.contains("the network adapter could not establish the connection") + || text.contains("io error") + || text.contains("ora-01017") + || text.contains("ora-12154") + || text.contains("ora-12514") + || text.contains("ora-12541"); + } + + private static String detail(DataAccessException exception) { + Throwable cause = exception.getMostSpecificCause(); + if (cause instanceof SQLException sqlException) { + return trim(sqlException.getMessage()); + } + return trim(cause == null ? exception.getMessage() : safeMessage(cause)); + } + + private static String safeMessage(Throwable throwable) { + String message = throwable.getMessage(); + return trim(message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message); + } + + private static String trim(String value) { + if (value == null || value.isBlank()) { + return "상세 메시지가 없습니다."; + } + String normalized = value.replaceAll("\\s+", " ").trim(); + return normalized.length() <= 300 ? normalized : normalized.substring(0, 300) + "..."; + } +} diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index b86cdbb..8cf81a8 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -9,10 +9,10 @@
보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.
-./run.sh backoffice-support