[Developer] #424 fix Oracle DATE token errors

Refs #424
This commit is contained in:
devmrko
2026-06-23 11:43:51 +09:00
parent 9e812042b6
commit a6dcaa1b63
9 changed files with 118 additions and 29 deletions

View File

@@ -1,6 +1,6 @@
package com.cloudhandson.vpdbackoffice.domain.token; package com.cloudhandson.vpdbackoffice.domain.token;
import java.time.OffsetDateTime; import java.time.LocalDateTime;
public record BearerTokenRecord( public record BearerTokenRecord(
long keyId, long keyId,
@@ -8,12 +8,12 @@ public record BearerTokenRecord(
String username, String username,
String keyPrefix, String keyPrefix,
String keyHash, String keyHash,
OffsetDateTime expiresAt, LocalDateTime expiresAt,
OffsetDateTime revokedAt, LocalDateTime revokedAt,
String description String description
) { ) {
public boolean active(OffsetDateTime now) { public boolean active(LocalDateTime now) {
return revokedAt == null && expiresAt != null && expiresAt.isAfter(now); return revokedAt == null && expiresAt != null && expiresAt.isAfter(now);
} }
} }

View File

@@ -1,7 +1,7 @@
package com.cloudhandson.vpdbackoffice.mapper; package com.cloudhandson.vpdbackoffice.mapper;
import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord; import com.cloudhandson.vpdbackoffice.domain.token.BearerTokenRecord;
import java.time.OffsetDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
@@ -19,7 +19,7 @@ public interface BearerTokenMapper {
int revokeToken( int revokeToken(
@Param("keyId") long keyId, @Param("keyId") long keyId,
@Param("revokedAt") OffsetDateTime revokedAt, @Param("revokedAt") LocalDateTime revokedAt,
@Param("reason") String reason @Param("reason") String reason
); );
} }

View File

@@ -9,7 +9,9 @@ import com.cloudhandson.vpdbackoffice.domain.user.AppUser;
import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper; import com.cloudhandson.vpdbackoffice.mapper.BearerTokenMapper;
import com.cloudhandson.vpdbackoffice.mapper.UserMapper; import com.cloudhandson.vpdbackoffice.mapper.UserMapper;
import java.time.Clock; import java.time.Clock;
import java.time.LocalDateTime;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.util.List; import java.util.List;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -81,6 +83,9 @@ public class BearerTokenService {
String prefix = tokenGenerator.prefix(plainToken); String prefix = tokenGenerator.prefix(plainToken);
String hash = tokenHasher.sha256(plainToken); String hash = tokenHasher.sha256(plainToken);
long keyId = tokenMapper.nextKeyId(); long keyId = tokenMapper.nextKeyId();
LocalDateTime expiresAt = command.expiresAt()
.atZoneSameInstant(ZoneId.systemDefault())
.toLocalDateTime();
tokenMapper.insertToken(new BearerTokenRecord( tokenMapper.insertToken(new BearerTokenRecord(
keyId, keyId,
@@ -88,7 +93,7 @@ public class BearerTokenService {
user.username(), user.username(),
prefix, prefix,
hash, hash,
command.expiresAt(), expiresAt,
null, null,
command.description() command.description()
)); ));
@@ -98,7 +103,11 @@ public class BearerTokenService {
@Transactional @Transactional
public void revokeToken(long keyId, String reason) { 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) { if (updated == 0) {
throw new AppException("회수할 활성 토큰을 찾을 수 없습니다."); throw new AppException("회수할 활성 토큰을 찾을 수 없습니다.");
} }

View File

@@ -13,7 +13,8 @@ import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI; import java.net.URI;
import java.time.Clock; import java.time.Clock;
import java.time.OffsetDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@@ -71,7 +72,7 @@ public class OrdsProbeService {
return auditAndReturn(command, ProbeResult.blocked( return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.TOKEN_NOT_FOUND, "TOKEN_NOT_FOUND", "토큰을 찾을 수 없습니다.")); 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( return auditAndReturn(command, ProbeResult.blocked(
ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다.")); ProbeStatus.TOKEN_INACTIVE, "TOKEN_INACTIVE", "만료되었거나 회수된 토큰입니다."));
} }

View File

@@ -17,13 +17,17 @@ public class AppExceptionHandler {
@ExceptionHandler(DataAccessException.class) @ExceptionHandler(DataAccessException.class)
public String handleDataAccessException(DataAccessException exception, Model model) { public String handleDataAccessException(DataAccessException exception, Model model) {
String detail = exception.getMostSpecificCause() == null RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(exception);
? exception.getMessage() model.addAttribute("errorTitle", error.title());
: exception.getMostSpecificCause().getMessage(); model.addAttribute("errorMessage", error.message());
model.addAttribute("errorTitle", "ADB 연결 설정이 필요합니다."); return "error";
model.addAttribute("errorMessage", }
"BACKOFFICE_DB_URL, BACKOFFICE_DB_USERNAME, BACKOFFICE_DB_PASSWORD를 실제 ADB 값으로 설정하고 "
+ "./run.sh backoffice-support를 실행한 뒤 다시 시도하세요. 상세: " + detail); @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"; return "error";
} }
} }

View File

@@ -33,18 +33,14 @@ public class DashboardController {
model.addAttribute("roles", permissionService.findRoles()); model.addAttribute("roles", permissionService.findRoles());
model.addAttribute("tokens", bearerTokenService.findAll()); model.addAttribute("tokens", bearerTokenService.findAll());
} catch (DataAccessException e) { } catch (DataAccessException e) {
RuntimeErrorMessage error = RuntimeErrorMessages.dataAccess(e);
model.addAttribute("objects", List.of()); model.addAttribute("objects", List.of());
model.addAttribute("roles", List.of()); model.addAttribute("roles", List.of());
model.addAttribute("tokens", 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"; 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;
}
} }

View File

@@ -0,0 +1,8 @@
package com.cloudhandson.vpdbackoffice.web;
public record RuntimeErrorMessage(
String title,
String message,
boolean showSupportCommand
) {
}

View File

@@ -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) + "...";
}
}

View File

@@ -9,10 +9,10 @@
<p>보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.</p> <p>보호 객체, Bearer Token, ORDS 검증 상태를 확인합니다.</p>
</div> </div>
<div class="alert alert-warning" th:if="${setupError}"> <div class="alert alert-warning" th:if="${runtimeErrorMessage}">
<div class="fw-semibold">DB 연결 설정이 필요합니다.</div> <div class="fw-semibold" th:text="${runtimeErrorTitle}">데이터 처리 오류가 발생했습니다.</div>
<div th:text="${setupError}"></div> <div th:text="${runtimeErrorMessage}"></div>
<div class="mt-2"> <div class="mt-2" th:if="${showSupportCommand}">
<code>./run.sh backoffice-support</code> <code>./run.sh backoffice-support</code>
</div> </div>
</div> </div>