From 822ca52ef0f1b148b6e516dc26166b635e620e54 Mon Sep 17 00:00:00 2001 From: curry772 Date: Fri, 11 Sep 2026 11:13:30 +0900 Subject: [PATCH] =?UTF-8?q?OAuth=20=ED=86=A0=ED=81=B0=20=ED=98=84=ED=99=A9?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EC=A7=84=EB=8B=A8=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 토큰이 발급되지 않는 사유를 message 에 모아서 표시 (useYn=N, 어댑터그룹 없음, 이 서버에 배정된 어댑터 없음, 캐시 없음 등) - tokenUrlResolved 추가 : 상대 경로 토큰 URL 을 어댑터 URL 과 조합한 실제 호출 주소 - adapterTokenServiceClasses 는 어댑터그룹을 찾지 못하면 null, 어댑터가 없으면 빈 값으로 구분 - 단위테스트 4건 추가 --- .../oauthtoken/OAuthTokenStatusDTO.java | 12 ++- .../oauthtoken/OAuthTokenStatusService.java | 101 +++++++++++++++--- .../OAuthTokenStatusServiceTest.java | 48 +++++++++ 3 files changed, 145 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java index 397baa1..590aa0e 100644 --- a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java +++ b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusDTO.java @@ -33,12 +33,22 @@ public class OAuthTokenStatusDTO { * * 어댑터마다 값이 다르면 스케줄러가 쓰는 구현체와 거래 중 재발급(retryAccessTokenVO) 때 * 쓰이는 구현체가 달라진다. 그 불일치를 확인하기 위한 항목이다. + * + * 어댑터그룹 자체를 찾지 못하면 null, 그룹은 있으나 이 서버에 배정된 어댑터가 없으면 빈 값이다. */ Map adapterTokenServiceClasses; - /** 토큰 발급 URL */ + /** 토큰 발급 URL. DB(OutboundOAuthCredentialVo)에 설정된 원본이라 상대 경로일 수 있다. */ String tokenUrl; + /** + * 실제로 호출되는 토큰 발급 URL. + * + * tokenUrl 이 절대 URL 이 아니면 어댑터 속성 URL 뒤에 붙여 호출하므로(구현체들의 appendPath 처리), + * 같은 방식으로 조합한 값이다. 어댑터 속성을 찾지 못하면 null. + */ + String tokenUrlResolved; + /** 토큰 재발급 주기(초) */ int intervalSec; diff --git a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java index e6c9bcd..1cdb6f4 100644 --- a/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java +++ b/src/main/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusService.java @@ -10,6 +10,7 @@ import java.util.Map; import java.util.Properties; import org.apache.commons.lang3.StringUtils; +import org.springframework.security.web.util.UrlUtils; import org.springframework.stereotype.Service; import com.eactive.eai.adapter.AdapterGroupVO; @@ -77,25 +78,50 @@ public class OAuthTokenStatusService { dto.setUseYn(credential.getUseYn()); dto.setTokenUrl(credential.getUrl()); dto.setIntervalSec(credential.getIntervalSec()); - dto.setTokenServiceClass(findTokenServiceClass(adapterGroupName)); - dto.setAdapterTokenServiceClasses(findAdapterTokenServiceClasses(adapterGroupName)); + // 스케줄러가 고르는 어댑터의 속성에서 발급 구현체와 기준 URL 을 함께 읽는다. + Properties schedulerAdapterProp = findSchedulerAdapterProperties(adapterGroupName); + if (schedulerAdapterProp != null) { + dto.setTokenServiceClass(schedulerAdapterProp.getProperty(PROP_TOKEN_SERVICE_CLASS)); + dto.setTokenUrlResolved(resolveTokenUrl(schedulerAdapterProp.getProperty("URL"), credential.getUrl())); + } + + Map adapterClasses = findAdapterTokenServiceClasses(adapterGroupName); + dto.setAdapterTokenServiceClasses(adapterClasses); + + // 확인이 필요한 상태는 모아서 message 하나로 돌려준다. + List notes = new ArrayList(); + + // init() 은 useYn 과 무관하게 인증 정보를 모두 등록하지만 startToken() 은 Y 일 때만 스케줄을 건다. + if (!"Y".equalsIgnoreCase(credential.getUseYn())) { + notes.add("OAuth 인증 정보가 사용 안 함(useYn=" + credential.getUseYn() + + ") 상태라 스케줄러가 토큰을 발급하지 않습니다."); + } + + if (adapterClasses == null) { + notes.add("어댑터그룹을 찾을 수 없습니다. 그룹명이 정확한지, 그룹 사용여부가 '사용' 인지 확인하세요."); + } else if (adapterClasses.isEmpty()) { + notes.add("어댑터그룹에 이 서버로 배정된 어댑터가 없어 토큰을 발급할 수 없습니다."); + } AccessTokenVO accessToken; try { accessToken = manager.peekAccessTokenVO(adapterGroupName); } catch (UnsupportedOperationException e) { - dto.setMessage("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다."); + notes.add("현재 SessionManager 백엔드는 아웃바운드 토큰 캐시를 지원하지 않습니다."); + dto.setMessage(joinNotes(notes)); return dto; } catch (Exception e) { if (logger.isWarn()) { logger.warn("토큰 캐시 조회 실패. adapterGroupName : " + adapterGroupName, e); } - dto.setMessage("토큰 캐시 조회 실패 : " + e.getMessage()); + notes.add("토큰 캐시 조회 실패 : " + e.getMessage()); + dto.setMessage(joinNotes(notes)); return dto; } if (accessToken == null) { - dto.setMessage("캐시에 토큰이 없습니다."); + notes.add("캐시에 토큰이 없습니다."); + dto.setMessage(joinNotes(notes)); return dto; } @@ -120,38 +146,82 @@ public class OAuthTokenStatusService { // 만료시각이 없는 토큰은 isExpired() 가 false 라 재발급 대상이 되지 않는다. // 빈 토큰이 캐시에 들어가면 기동 후 계속 그대로 사용되므로 조회 시 눈에 띄게 알린다. if (StringUtils.isBlank(accessToken.getAccessToken())) { - dto.setMessage("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다." + notes.add("캐시에 빈 토큰이 있습니다. 발급에 실패했는데도 구현체가 빈 토큰을 반환한 것으로 보입니다." + " 만료시각이 없어 자동 재발급되지 않습니다."); } else if (expiration == null) { - dto.setMessage("만료시각이 없어 자동 재발급되지 않습니다."); + notes.add("만료시각이 없어 자동 재발급되지 않습니다."); } + dto.setMessage(joinNotes(notes)); + return dto; } /** - * 어댑터 속성에서 토큰 발급 구현체 클래스명을 찾는다. + * 확인이 필요한 상태 메시지를 하나로 합친다. + * + * @param notes 메시지 목록 + * @return 합친 메시지. 없으면 null. + */ + private String joinNotes(List notes) { + if (notes.isEmpty()) { + return null; + } + return StringUtils.join(notes, " "); + } + + /** + * 스케줄러가 토큰 발급에 사용하는 어댑터의 속성을 찾는다. + * + * AccessTokenManagerByDB.issueToken() 과 동일하게 getAdapters().next() 로 어댑터 하나를 고른다. * * @param adapterGroupName 어댑터그룹명 - * @return 구현체 클래스명. 찾지 못하면 null. + * @return 어댑터 속성. 찾지 못하면 null. */ - private String findTokenServiceClass(String adapterGroupName) { + private Properties findSchedulerAdapterProperties(String adapterGroupName) { try { AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName); if (gvo == null || !gvo.getAdapters().hasNext()) { return null; } AdapterVO avo = gvo.getAdapters().next(); - Properties properties = AdapterPropManager.getInstance().getProperties(avo.getPropGroupName()); - return properties.getProperty(PROP_TOKEN_SERVICE_CLASS); + return AdapterPropManager.getInstance().getProperties(avo.getPropGroupName()); } catch (Exception e) { if (logger.isDebug()) { - logger.debug("토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName); + logger.debug("어댑터 속성 조회 실패. adapterGroupName : " + adapterGroupName); } return null; } } + /** + * 실제로 호출되는 토큰 발급 URL 을 만든다. + * + * 구현체들과 동일하게, 절대 URL 이 아니면 어댑터 속성 URL 뒤에 이어 붙인다. + * + * @param adapterUrl 어댑터 속성의 URL + * @param tokenUrl DB 에 설정된 토큰 발급 URL + * @return 조합된 URL. 만들 수 없으면 null. + */ + String resolveTokenUrl(String adapterUrl, String tokenUrl) { + if (StringUtils.isBlank(tokenUrl)) { + return null; + } + if (UrlUtils.isAbsoluteUrl(tokenUrl)) { + return tokenUrl; + } + if (StringUtils.isBlank(adapterUrl)) { + return null; + } + if (!adapterUrl.endsWith("/") && !tokenUrl.startsWith("/")) { + return adapterUrl + "/" + tokenUrl; + } + if (adapterUrl.endsWith("/") && tokenUrl.startsWith("/")) { + return adapterUrl + tokenUrl.substring(1); + } + return adapterUrl + tokenUrl; + } + /** * 그룹에 속한 어댑터별로 토큰 발급 구현체 클래스명을 모은다. * @@ -160,14 +230,14 @@ public class OAuthTokenStatusService { * 서로 다른 구현체가 동작하므로 전체를 보여준다. * * @param adapterGroupName 어댑터그룹명 - * @return 어댑터명 → 구현체 클래스명. 조회 실패 시 빈 Map. + * @return 어댑터명 → 구현체 클래스명. 어댑터그룹 자체를 찾지 못하면 null, 어댑터가 없으면 빈 Map. */ private Map findAdapterTokenServiceClasses(String adapterGroupName) { Map result = new LinkedHashMap(); try { AdapterGroupVO gvo = AdapterManager.getInstance().getAdapterGroupVO(adapterGroupName); if (gvo == null) { - return result; + return null; } Iterator adapters = gvo.getAdapters(); while (adapters.hasNext()) { @@ -179,6 +249,7 @@ public class OAuthTokenStatusService { if (logger.isDebug()) { logger.debug("어댑터별 토큰 발급 구현체 조회 실패. adapterGroupName : " + adapterGroupName); } + return null; } return result; } diff --git a/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java b/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java index aee02be..c60d2ed 100644 --- a/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java +++ b/src/test/java/com/eactive/eai/manage/oauthtoken/OAuthTokenStatusServiceTest.java @@ -209,6 +209,54 @@ class OAuthTokenStatusServiceTest { assertTrue(dto.getMessage().contains("재발급")); } + @Test + @DisplayName("8-3. useYn 이 N 이면 스케줄러가 발급하지 않음을 알린다") + void 사용안함경고() { + OutboundOAuthCredentialVo credential = registerCredential(GROUP); + credential.setUseYn("N"); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + assertTrue(dto.getMessage().contains("사용 안 함")); + assertTrue(dto.getMessage().contains("캐시에 토큰이 없습니다"), "다른 사유도 함께 표시돼야 한다"); + } + + @Test + @DisplayName("8-4. 어댑터그룹을 찾지 못하면 그 사유를 알린다") + void 어댑터그룹없음경고() { + registerCredential(GROUP); + Mockito.when(mockManager.peekAccessTokenVO(GROUP)).thenReturn(null); + + OAuthTokenStatusDTO dto = service.getStatus(GROUP); + + // 테스트 환경에는 AdapterManager 가 없으므로 그룹을 찾지 못한 것으로 처리된다. + assertNull(dto.getAdapterTokenServiceClasses()); + assertTrue(dto.getMessage().contains("어댑터그룹을 찾을 수 없습니다")); + } + + @Test + @DisplayName("8-5. 상대 경로 토큰 URL 은 어댑터 URL 과 조합한다") + void 토큰URL조합() { + assertEquals("https://api.example.com/oauth2/token", + service.resolveTokenUrl("https://api.example.com", "oauth2/token")); + assertEquals("https://api.example.com/oauth2/token", + service.resolveTokenUrl("https://api.example.com/", "oauth2/token")); + assertEquals("https://api.example.com/oauth2/token", + service.resolveTokenUrl("https://api.example.com", "/oauth2/token")); + assertEquals("https://api.example.com/oauth2/token", + service.resolveTokenUrl("https://api.example.com/", "/oauth2/token")); + } + + @Test + @DisplayName("8-6. 절대 URL 은 그대로 쓰고, 조합할 수 없으면 null") + void 토큰URL절대경로() { + assertEquals("https://token.example.com/oauth/token", + service.resolveTokenUrl("https://api.example.com", "https://token.example.com/oauth/token")); + assertNull(service.resolveTokenUrl("https://api.example.com", null)); + assertNull(service.resolveTokenUrl(null, "oauth2/token")); + } + @Test @DisplayName("9. 전체 목록은 등록된 어댑터그룹 수만큼 반환한다") void 전체목록() {