3 Commits

Author SHA1 Message Date
Rinjae a6807a37c2 - API 감사 로거 추가 - APITesterAuditLogger 클래스 생성
- 회원가입 링크 수정 - href 속성 추가
- Testbed UI 스타일 추가 - CSS/SASS 코드 업데이트
2026-07-23 20:28:12 +09:00
Rinjae 6c10ae12c7 Merge branch 'master' into design 2026-07-23 20:26:55 +09:00
Rinjae 01c4a80182 API 테스트베드 개선 및 감사 로그 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- Gateway 프로퍼티 mock 토큰 관련 항목 추가
- API 요청/응답 감사 로그 작성 로직 구현 및 로깅 설정 추가
- Swagger UI 전용 spec 제공 및 서버 주소 치환 로직 분리
2026-07-23 20:26:40 +09:00
10 changed files with 351 additions and 20 deletions
@@ -68,8 +68,16 @@ public class ApiTesterFilter implements Filter {
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class); ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
String url = httpServletRequest.getHeader("original-url"); String url = httpServletRequest.getHeader("original-url");
// 감사 로그: 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다 (전용 파일, 1년 보관)
String auditId = ApiTesterAuditLogger.newAuditId();
long auditStart = System.currentTimeMillis();
String auditType = "-";
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지) // original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
if (url == null || url.trim().isEmpty()) { if (url == null || url.trim().isEmpty()) {
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, null, "-", false, null);
ApiTesterAuditLogger.logResult(auditId, HttpServletResponse.SC_BAD_REQUEST, "BAD_REQUEST",
System.currentTimeMillis() - auditStart);
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}"); writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
return; return;
} }
@@ -84,16 +92,15 @@ public class ApiTesterFilter implements Filter {
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH) boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|| url.contains(gatewayProperty.tokenPath()); || url.contains(gatewayProperty.tokenPath());
// 본문은 한 번만 읽어 프록시 forward 와 감사 로그에 함께 사용 (GET 이면 빈 문자열)
String requestBody = readBody(httpServletRequest);
ApiTesterAuditLogger.logRequest(auditId, httpServletRequest, url, gatewayMode.name(), tokenRequest, requestBody);
if (tokenRequest) { if (tokenRequest) {
StringBuilder sb = new StringBuilder(); String body = requestBody;
BufferedReader reader = httpServletRequest.getReader();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) { if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
auditType = "TOKEN_MOCK";
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지) // PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
Map<String, String> params = new HashMap<>(); Map<String, String> params = new HashMap<>();
String[] pairs = body.split("&"); String[] pairs = body.split("&");
@@ -106,7 +113,7 @@ public class ApiTesterFilter implements Filter {
String scope = params.getOrDefault("scope", "default"); String scope = params.getOrDefault("scope", "default");
String token = "{\n" + String token = "{\n" +
" \"access_token\": \"djbank_gw_sample_token\",\n" + " \"access_token\": \"" + escapeJson(gatewayProperty.mockAccessToken()) + "\",\n" +
" \"token_type\": \"bearer\",\n" + " \"token_type\": \"bearer\",\n" +
" \"expires_in\": 86400,\n" + " \"expires_in\": 86400,\n" +
" \"scope\": \""+scope +"\",\n" + " \"scope\": \""+scope +"\",\n" +
@@ -117,6 +124,7 @@ public class ApiTesterFilter implements Filter {
response.getWriter().println(token); response.getWriter().println(token);
} else { } else {
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만) // GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
auditType = "TOKEN_GW";
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/x-www-form-urlencoded"); headers.put("Content-Type", "application/x-www-form-urlencoded");
@@ -133,6 +141,7 @@ public class ApiTesterFilter implements Filter {
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지) // URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
if (apiSpecInfoDto == null) { if (apiSpecInfoDto == null) {
auditType = "SPEC_NOT_FOUND";
writeJson(response, HttpServletResponse.SC_NOT_FOUND, writeJson(response, HttpServletResponse.SC_NOT_FOUND,
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}"); "{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
return; return;
@@ -142,6 +151,7 @@ public class ApiTesterFilter implements Filter {
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음) // sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
if (responseType == null || responseType.equalsIgnoreCase("sample")) { if (responseType == null || responseType.equalsIgnoreCase("sample")) {
auditType = "SAMPLE";
response.setContentType("application/json"); response.setContentType("application/json");
response.getWriter().println(apiSpecInfoDto.getSampleResponse()); response.getWriter().println(apiSpecInfoDto.getSampleResponse());
return; return;
@@ -151,6 +161,7 @@ public class ApiTesterFilter implements Filter {
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path) // - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
// - mock : ApiSpecInfo.mockUrl (기존 동작) // - mock : ApiSpecInfo.mockUrl (기존 동작)
boolean gw = "gw".equalsIgnoreCase(responseType); boolean gw = "gw".equalsIgnoreCase(responseType);
auditType = gw ? "GW" : "MOCK";
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = new HashMap<>();
Enumeration<String> headerNames = httpServletRequest.getHeaderNames(); Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
@@ -160,6 +171,10 @@ public class ApiTesterFilter implements Filter {
} }
headers.remove("original-url"); headers.remove("original-url");
headers.remove("original-api-id"); headers.remove("original-api-id");
// readBody()가 개행을 제거해 원본 Content-Length와 실제 전송 바이트가 달라질 수 있고,
// WebLogic HTTP 클라이언트는 이 불일치를 IOException으로 처리하므로 length 계열 헤더는
// 전달하지 않는다(HttpURLConnection이 실제 바이트 수로 재설정).
headers.keySet().removeIf(k -> "content-length".equalsIgnoreCase(k) || "transfer-encoding".equalsIgnoreCase(k));
String targetUri; String targetUri;
Map<String, String[]> paramMap; Map<String, String[]> paramMap;
@@ -179,7 +194,7 @@ public class ApiTesterFilter implements Filter {
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class); APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
String responseStr; String responseStr;
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) { if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest)); responseStr = apiSender.requestPost(targetUri, headers, paramMap, requestBody);
} else { } else {
responseStr = apiSender.requestGet(targetUri, headers, paramMap); responseStr = apiSender.requestGet(targetUri, headers, paramMap);
} }
@@ -202,6 +217,9 @@ public class ApiTesterFilter implements Filter {
logger.error("테스트베드 프록시 처리 오류", e); logger.error("테스트베드 프록시 처리 오류", e);
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}"); "{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
} finally {
ApiTesterAuditLogger.logResult(auditId, ((HttpServletResponse) response).getStatus(), auditType,
System.currentTimeMillis() - auditStart);
} }
} }
@@ -32,6 +32,7 @@ public class DjbTestbedGatewayProperty {
public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout"; public static final String KEY_TIMEOUT_SEC = "djb.gateway.timeout";
public static final String KEY_USE_PROXY = "djb.gateway.use-proxy"; public static final String KEY_USE_PROXY = "djb.gateway.use-proxy";
public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy"; public static final String KEY_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
public static final String KEY_MOCK_ACCESS_TOKEN = "djb.gateway.mock-access-token";
public static final String DEFAULT_BASE_URL = "PortalMock"; public static final String DEFAULT_BASE_URL = "PortalMock";
public static final String DEFAULT_TIMEOUT_SEC = "10"; public static final String DEFAULT_TIMEOUT_SEC = "10";
@@ -47,6 +48,9 @@ public class DjbTestbedGatewayProperty {
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */ /** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token"; public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
/** PortalMock 모드 토큰 응답의 access_token 기본값. */
public static final String DEFAULT_MOCK_ACCESS_TOKEN = "djbank_gw_sample_token";
public String baseUrl() { public String baseUrl() {
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL, return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환"); "GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
@@ -86,6 +90,12 @@ public class DjbTestbedGatewayProperty {
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true); "테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
} }
/** PortalMock 모드 토큰 응답에 넣을 access_token 값. */
public String mockAccessToken() {
return resolve(KEY_MOCK_ACCESS_TOKEN, DEFAULT_MOCK_ACCESS_TOKEN,
"PortalMock 모드 토큰 발급 응답의 access_token 값");
}
/** /**
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다. * 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}. * {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
@@ -25,8 +25,11 @@ import org.springframework.web.bind.annotation.RestController;
/** /**
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고, * testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에 * 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 실주소로 치환해 반환한다.
* 따른 실주소로 치환해 반환한다. * <ul>
* <li>{@code swagger.json}/{@code swagger.yaml} : 외부 공개/다운로드용 — 항상 GW 주소로 치환</li>
* <li>{@code swagger-ui.json} : Swagger UI 전용 — API SPEC 설정(responseType: sample/mock/gw)에 따라 치환</li>
* </ul>
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외). * {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
*/ */
@RestController @RestController
@@ -45,22 +48,34 @@ public class DjbTestbedSpecController {
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException { public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request); String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
} }
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml") @GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException { public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request); String json = buildSpecJson(id, request, true);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json)); return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
} }
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */ /** Swagger UI 전용 spec — 서버 주소를 responseType(sample/mock/gw) 설정에 따라 치환. */
private String buildSpecJson(String id, HttpServletRequest request) throws IOException { @GetMapping(value = "/{id}/swagger-ui.json", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> swaggerForUi(@PathVariable String id, HttpServletRequest request) throws IOException {
String json = buildSpecJson(id, request, false);
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
}
/**
* default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null.
* @param alwaysGateway true 면 항상 GW 주소 치환(다운로드용), false 면 responseType 설정 기반(UI용)
*/
private String buildSpecJson(String id, HttpServletRequest request, boolean alwaysGateway) throws IOException {
if (DEFAULT_TOKEN_API_ID.equals(id)) { if (DEFAULT_TOKEN_API_ID.equals(id)) {
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH); Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8); String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
return serverRewriter.rewriteServer(content, null, request); return alwaysGateway
? serverRewriter.rewriteServerToGateway(content, request)
: serverRewriter.rewriteServer(content, null, request);
} }
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id); Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
@@ -70,6 +85,8 @@ public class DjbTestbedSpecController {
DjbAuthType authType = authService.resolveAuthType(id); DjbAuthType authType = authService.resolveAuthType(id);
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType); String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
return serverRewriter.rewriteServer(enriched, spec.get(), request); return alwaysGateway
? serverRewriter.rewriteServerToGateway(enriched, request)
: serverRewriter.rewriteServer(enriched, spec.get(), request);
} }
} }
@@ -50,6 +50,22 @@ public class DjbTestbedSpecServerRewriter {
return specJson.replace(SERVER_SENTINEL, base); return specJson.replace(SERVER_SENTINEL, base);
} }
/**
* sentinel → GW 주소({@link DjbTestbedGatewayProperty#resolveApiBaseUrl}) 치환한 spec JSON 반환.
* responseType 을 무시하고 항상 GW 기준으로 치환한다 — 외부 공개/다운로드용 spec(swagger.json/yaml)
* 단일 기준. (Swagger UI 표시용은 {@link #rewriteServer} 의 responseType 분기를 그대로 사용.)
*/
public String rewriteServerToGateway(String specJson, HttpServletRequest request) {
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
return specJson;
}
String base = stripTrailingSlash(gatewayProperty.resolveApiBaseUrl(originOf(request)));
if (!StringUtils.hasText(base)) {
return specJson; // 실주소 미확정 시 sentinel 유지
}
return specJson.replace(SERVER_SENTINEL, base);
}
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */ /** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
public String toYaml(String specJson) { public String toYaml(String specJson) {
try { try {
+17
View File
@@ -56,6 +56,19 @@
</encoder> </encoder>
</appender> </appender>
<!-- API 테스트베드(/api/call-api) 감사 로그: 요청지/헤더/마스킹된 본문 기록, 1년(365일) 보관 -->
<appender name="API_TESTER_AUDIT" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/apitester-audit.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/backup/apitester-audit.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>365</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %msg%n</pattern>
</encoder>
</appender>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<filter class="ch.qos.logback.classic.filter.ThresholdFilter"> <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>${CONSOLE_EFFECTIVE_LEVEL}</level> <level>${CONSOLE_EFFECTIVE_LEVEL}</level>
@@ -68,6 +81,10 @@
<appender-ref ref="HTTP_SESSION" /> <appender-ref ref="HTTP_SESSION" />
</logger> </logger>
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
<appender-ref ref="API_TESTER_AUDIT" />
</logger>
<root level="INFO"> <root level="INFO">
<appender-ref ref="ROLLING"/> <appender-ref ref="ROLLING"/>
+118
View File
@@ -10815,6 +10815,124 @@ body.index-page-body {
display: flex; display: flex;
} }
#testbed-tab {
gap: 0;
}
#testbed-tab #swagger-ui .info {
margin: 24px 0;
}
.testbed-app-panel {
margin: 8px 0 0;
padding: 24px;
background: #FFFFFF;
border: 1px solid #E2E8F0;
border-radius: 12px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.testbed-app-panel .testbed-app-panel__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
margin-bottom: 16px;
}
.testbed-app-panel .testbed-app-panel__title {
position: relative;
padding-left: 16px;
font-size: 16px;
font-weight: 700;
color: #1A1A2E;
letter-spacing: -0.01em;
}
.testbed-app-panel .testbed-app-panel__title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 15px;
background: #0049b4;
border-radius: 2px;
}
.testbed-app-panel .testbed-app-panel__desc {
font-size: 14px;
color: #64748B;
}
.testbed-app-panel .testbed-app-field {
position: relative;
max-width: 380px;
}
.testbed-app-panel .testbed-app-field::after {
content: "";
position: absolute;
right: 16px;
top: 50%;
width: 9px;
height: 9px;
margin-top: -6px;
border-right: 2px solid #64748B;
border-bottom: 2px solid #64748B;
transform: rotate(45deg);
pointer-events: none;
}
.testbed-app-panel #apps {
width: 100%;
height: 46px;
padding: 0 42px 0 16px;
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
color: #1A1A2E;
background: #F8FAFC;
border: 1px solid #E2E8F0;
border-radius: 8px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: all 0.15s ease;
}
.testbed-app-panel #apps:hover:not(:disabled) {
border-color: #94A3B8;
background: #FFFFFF;
}
.testbed-app-panel #apps:focus {
outline: none;
background: #FFFFFF;
border-color: #0049b4;
box-shadow: 0 0 0 3px rgba(0, 73, 180, 0.14);
}
.testbed-app-panel #apps:disabled {
color: #94A3B8;
background: #F8FAFC;
cursor: not-allowed;
}
.testbed-app-panel .testbed-app-notice {
margin: 16px 0 0;
padding: 11px 16px;
font-size: 14px;
line-height: 1.6;
color: #64748B;
background: #EFF6FF;
border: 1px solid rgba(0, 73, 180, 0.18);
border-left: 3px solid #0049b4;
border-radius: 6px;
}
.testbed-app-panel .testbed-app-notice::before {
content: "ⓘ ";
color: #0049b4;
font-weight: 700;
}
@media (max-width: 768px) {
.testbed-app-panel {
padding: 16px;
}
.testbed-app-panel .testbed-app-field {
max-width: 100%;
}
}
.api-overview-card { .api-overview-card {
background: transparent; background: transparent;
border-radius: 0; border-radius: 0;
File diff suppressed because one or more lines are too long
@@ -697,6 +697,139 @@
} }
} }
// Testbed 탭: 앱 선택 패널과 Swagger UI 사이 여백 제거
#testbed-tab {
gap: 0;
#swagger-ui .info {
margin: $spacing-lg 0;
}
}
// Testbed 앱 선택 패널 (DJPGPT0001)
.testbed-app-panel {
margin: $spacing-sm 0 0;
padding: $spacing-lg;
background: $white;
border: 1px solid $border-gray;
border-radius: $border-radius-lg;
box-shadow: $shadow-sm;
.testbed-app-panel__head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: $spacing-xs $spacing-sm;
margin-bottom: $spacing-md;
}
.testbed-app-panel__title {
position: relative;
padding-left: $spacing-md;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $text-dark;
letter-spacing: -0.01em;
&::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 15px;
background: $primary-blue;
border-radius: 2px;
}
}
.testbed-app-panel__desc {
font-size: $font-size-sm;
color: $text-gray;
}
.testbed-app-field {
position: relative;
max-width: 380px;
&::after {
content: "";
position: absolute;
right: $spacing-md;
top: 50%;
width: 9px;
height: 9px;
margin-top: -6px;
border-right: 2px solid $text-gray;
border-bottom: 2px solid $text-gray;
transform: rotate(45deg);
pointer-events: none;
}
}
#apps {
width: 100%;
height: 46px;
padding: 0 42px 0 $input-padding-x;
font-family: $font-family-primary;
font-size: $font-size-sm;
color: $text-dark;
background: $gray-bg;
border: 1px solid $border-gray;
border-radius: $border-radius-md;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
cursor: pointer;
transition: $transition-fast;
&:hover:not(:disabled) {
border-color: $text-light;
background: $white;
}
&:focus {
outline: none;
background: $white;
border-color: $primary-blue;
box-shadow: 0 0 0 3px rgba($primary-blue, 0.14);
}
&:disabled {
color: $text-light;
background: $gray-bg;
cursor: not-allowed;
}
}
.testbed-app-notice {
margin: $spacing-md 0 0;
padding: 11px $spacing-md;
font-size: $font-size-sm;
line-height: $line-height-normal;
color: $text-gray;
background: $light-bg;
border: 1px solid rgba($primary-blue, 0.18);
border-left: 3px solid $primary-blue;
border-radius: $border-radius-sm;
&::before {
content: "";
color: $primary-blue;
font-weight: $font-weight-bold;
}
}
@media (max-width: $breakpoint-sm) {
padding: $spacing-md;
.testbed-app-field {
max-width: 100%;
}
}
}
// API Overview Card (Flat Style) // API Overview Card (Flat Style)
.api-overview-card { .api-overview-card {
background: transparent; background: transparent;
@@ -305,7 +305,9 @@
return; return;
} }
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger.json`; // UI 전용 spec: 서버 주소가 responseType(sample/mock/gw) 설정에 따라 치환됨.
// (swagger.json 은 외부 다운로드용으로 항상 GW 주소 고정)
const swaggerUrl = `/djb/testbed/apis/${apiId}/swagger-ui.json`;
// Load Swagger UI scripts dynamically // Load Swagger UI scripts dynamically
const loadScript = (src) => { const loadScript = (src) => {
@@ -71,7 +71,7 @@
<div class="auth-group" sec:authorize="isAnonymous()"> <div class="auth-group" sec:authorize="isAnonymous()">
<a th:href="@{/login}" class="login-btn login-btn-box">로그인</a> <a th:href="@{/login}" class="login-btn login-btn-box">로그인</a>
<a href="#" class="btn-signup">회원가입</a> <a th:href="@{/signup}" class="btn-signup">회원가입</a>
</div> </div>
<!-- Logout State (Authenticated) --> <!-- Logout State (Authenticated) -->