Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 045f354e5c | |||
| 4f577c8c14 | |||
| cc4c1a6b53 | |||
| 551a2da717 | |||
| 1c57d348e6 |
@@ -107,4 +107,9 @@ TODO.txt
|
||||
diff
|
||||
|
||||
*rinjae*
|
||||
*gf63*
|
||||
*gf63*
|
||||
|
||||
# Local publishing and config files
|
||||
/publishing/
|
||||
/src/main/resources/application-local.yml
|
||||
/src/main/resources/logback-local.xml
|
||||
@@ -3,7 +3,6 @@ plugins {
|
||||
id 'war'
|
||||
id 'eclipse'
|
||||
id 'idea'
|
||||
id 'org.cyclonedx.bom' version '3.2.4'
|
||||
id 'org.springframework.boot' version '2.7.18'
|
||||
id 'io.spring.dependency-management' version '1.1.3'
|
||||
}
|
||||
@@ -30,12 +29,9 @@ dependencies {
|
||||
|
||||
implementation 'com.eactive.elink.common:elink-common-data:4.5.5'
|
||||
|
||||
// OpenAPI 3.0/3.1 spec 지원 (Nexus/Maven 해석). 기존 libs/swagger fileTree 대체.
|
||||
// swagger 최신 릴리스(2.2.52/2.1.45)도 SpecVersion 은 V30/V31 까지만 — OpenAPI 3.2 는
|
||||
// 아직 swagger-core/parser 어느 버전도 미지원. 지원 추가 시 버전만 상향하면 됨. JDK8 호환.
|
||||
implementation 'io.swagger.core.v3:swagger-core:2.2.52' // io.swagger.v3.oas.models.*, io.swagger.v3.core.util.Json
|
||||
implementation 'io.swagger.parser.v3:swagger-parser:2.1.45' // io.swagger.parser.OpenAPIParser, io.swagger.v3.parser.*
|
||||
implementation 'io.swagger:swagger-annotations:1.6.14' // io.swagger.annotations.ApiModelProperty (CommissionSearch)
|
||||
// implementation 'io.swagger.core.v3:swagger-core:2.2.25'
|
||||
// implementation 'io.swagger.parser.v3:swagger-parser:2.1.23'
|
||||
implementation fileTree(dir: 'libs/swagger', include: ['*.jar'])
|
||||
|
||||
implementation project(':elink-online-core-jpa')
|
||||
implementation project(':elink-portal-common')
|
||||
|
||||
@@ -254,7 +254,8 @@ CREATE TABLE DVPOWN.PT_MESSAGE_RECIPIENT
|
||||
)
|
||||
;
|
||||
|
||||
create table DVPOWN.PT_TOKEN
|
||||
create table PT_TOKEN
|
||||
(
|
||||
(
|
||||
TOKEN VARCHAR2(255) not null
|
||||
primary key,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
-- =============================================================================
|
||||
-- 2FA 인증코드 조회용 결정적 해시 키 컬럼 추가
|
||||
-- 대상: EMS 스키마 소유자(EMSAPP 등)로 접속하여 실행
|
||||
-- 엔티티: com.eactive.apim.portal.portaluser.entity.TwoFactorAuth
|
||||
--
|
||||
-- 배경: RECIPIENT(이메일/휴대폰) 컬럼에 PersonalDataEncryptConverter 암호화가
|
||||
-- 적용되면서, 값 동등 조회(WHERE RECIPIENT = ?)와 중복정리가 깨져
|
||||
-- 인증코드 검증이 "일치하지 않습니다"로 실패함.
|
||||
-- 해결: RECIPIENT 는 암호화 보관(감사/표시용)을 유지하고, 평문의 결정적 해시
|
||||
-- (HMAC-SHA256, 소문자 hex 64자)를 RECIPIENT_KEY 에 저장하여 조회/정리에 사용.
|
||||
--
|
||||
-- ※ hibernate.hbm2ddl.auto=validate 이므로, 신규 엔티티 배포(재기동) "전"에
|
||||
-- 반드시 이 스크립트를 먼저 실행해야 기동 검증을 통과함.
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE PTL_TWO_FACTOR_AUTH ADD (RECIPIENT_KEY VARCHAR2(64));
|
||||
|
||||
-- 인증코드 조회/중복정리는 RECIPIENT_KEY 기준
|
||||
CREATE INDEX IX_PTL_TWO_FACTOR_AUTH_RKEY ON PTL_TWO_FACTOR_AUTH (RECIPIENT_KEY);
|
||||
|
||||
COMMENT ON COLUMN PTL_TWO_FACTOR_AUTH.RECIPIENT_KEY IS '수신자 결정적 해시 조회키 (HMAC-SHA256 hex). RECIPIENT 암호화로 깨지는 동등조회/중복정리용';
|
||||
|
||||
COMMIT;
|
||||
@@ -22,6 +22,10 @@ public class PortalApplication extends SpringBootServletInitializer {
|
||||
|
||||
private static final Logger portal_logger = LoggerFactory.getLogger(PortalApplication.class);
|
||||
|
||||
public PortalApplication() {
|
||||
super();
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
|
||||
portal_logger.info("##### PortalApplication Start #####");
|
||||
|
||||
@@ -2,15 +2,15 @@ package com.eactive.apim.portal.apps.apis.controller;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiSpecService;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
@@ -22,45 +22,35 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/apis")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class TestbedSpecController {
|
||||
|
||||
private final ApiSpecInfoService apiSpecInfoService;
|
||||
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||
@Autowired
|
||||
private ApiSpecInfoService apiSpecInfoService;
|
||||
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> getSwagger(@PathVariable String id, HttpServletRequest request) {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
public ResponseEntity<String> getSwagger(@PathVariable String id) {
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
public ResponseEntity<String> getSwaggerYaml(@PathVariable String id, HttpServletRequest request) {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(서버 sentinel → 설정별 실주소 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
private String buildSpecJson(String id, HttpServletRequest request) {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
try {
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
return serverRewriter.rewriteServer(content, null, request);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()));
|
||||
return ResponseEntity.ok().body(content);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read default token api spec file", e);
|
||||
return null;
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||
}
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
|
||||
if (!spec.isPresent()) {
|
||||
StringUtils.hasText(spec.get().getTestbedSpec());
|
||||
}
|
||||
return serverRewriter.rewriteServer(spec.get().getTestbedSpec(), spec.get(), request);
|
||||
return ResponseEntity.ok().body(spec.get().getTestbedSpec());
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.eactive.apim.portal.apps.apis.filter;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.IOException;
|
||||
@@ -21,20 +20,6 @@ public class APISender {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(APISender.class);
|
||||
|
||||
// 테스트베드 프록시 연결/응답 타임아웃 — DjbTestbedGatewayProperty(djb.gateway.timeout, 단위: 초) 단일 기준.
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
public APISender(DjbTestbedGatewayProperty gatewayProperty) {
|
||||
this.gatewayProperty = gatewayProperty;
|
||||
}
|
||||
|
||||
/** connect/read 타임아웃 적용. 반드시 connect(getOutputStream/getResponseCode) 이전에 호출. */
|
||||
private void applyTimeout(HttpURLConnection connection) {
|
||||
int t = gatewayProperty.timeoutMillis();
|
||||
connection.setConnectTimeout(t);
|
||||
connection.setReadTimeout(t);
|
||||
}
|
||||
|
||||
public String requestPost(String uri, String requestBody) throws IOException {
|
||||
|
||||
HttpURLConnection connection = getHttpURLConnection(uri, requestBody);
|
||||
@@ -73,7 +58,6 @@ public class APISender {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
connection.setRequestMethod("GET");
|
||||
connection.setRequestProperty("Accept", "application/json");
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
@@ -98,7 +82,6 @@ public class APISender {
|
||||
|
||||
URL endpoint = new URL(appendUriAndParams(uri, params));
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
connection.setRequestMethod("POST");
|
||||
|
||||
for (Map.Entry<String, String> entry : headers.entrySet()) {
|
||||
@@ -142,11 +125,10 @@ public class APISender {
|
||||
return response.toString();
|
||||
}
|
||||
|
||||
private HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
private static HttpURLConnection getHttpURLConnection(String uri, String requestBody) throws IOException {
|
||||
URL endpoint = new URL(uri);
|
||||
|
||||
HttpURLConnection connection = (HttpURLConnection) endpoint.openConnection();
|
||||
applyTimeout(connection);
|
||||
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
|
||||
@@ -4,8 +4,6 @@ package com.eactive.apim.portal.apps.apis.filter;
|
||||
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
|
||||
import com.eactive.apim.portal.apps.apis.service.ApiService;
|
||||
import com.eactive.apim.portal.common.util.ApplicationContextUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
@@ -22,7 +20,6 @@ import javax.servlet.ServletRequest;
|
||||
import javax.servlet.ServletResponse;
|
||||
import javax.servlet.annotation.WebFilter;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@@ -68,23 +65,7 @@ public class ApiTesterFilter implements Filter {
|
||||
ApiService apiSpecInfoDtoService = ApplicationContextUtil.getContext().getBean(ApiService.class);
|
||||
String url = httpServletRequest.getHeader("original-url");
|
||||
|
||||
// original-url 헤더가 없으면 프록시 대상을 알 수 없음 → 400 (NPE 방지)
|
||||
if (url == null || url.trim().isEmpty()) {
|
||||
writeJson(response, HttpServletResponse.SC_BAD_REQUEST, "{\"error\":\"original-url 헤더가 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 실제 프록시 호출(토큰/gw/mock)은 네트워크 오류·타임아웃도 응답 content-type(JSON)에 맞춰
|
||||
// 반환하기 위해 try 로 감싼다.
|
||||
try {
|
||||
|
||||
// 게이트웨이 모드에 따라 OAuth 토큰 발급 요청을 mock 또는 실 게이트웨이 forward 로 분기 (DJPGPT0001)
|
||||
DjbTestbedGatewayProperty gatewayProperty = ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class);
|
||||
DjbGatewayMode gatewayMode = gatewayProperty.resolveGatewayMode();
|
||||
boolean tokenRequest = url.contains(DjbTestbedGatewayProperty.PORTAL_MOCK_TOKEN_PATH)
|
||||
|| url.contains(gatewayProperty.tokenPath());
|
||||
|
||||
if (tokenRequest) {
|
||||
if (url.contains("/api/v1/oauth/token")){
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
@@ -93,174 +74,82 @@ public class ApiTesterFilter implements Filter {
|
||||
}
|
||||
String body = sb.toString();
|
||||
|
||||
if (gatewayMode == DjbGatewayMode.PORTAL_MOCK) {
|
||||
// PortalMock: 고정 mock 토큰 반환 (기존 동작 유지)
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
String[] keyValue = pair.split("=");
|
||||
if (keyValue.length == 2) {
|
||||
params.put(keyValue[0], keyValue[1]);
|
||||
}
|
||||
// Parse request parameters
|
||||
Map<String, String> params = new HashMap<>();
|
||||
String[] pairs = body.split("&");
|
||||
for (String pair : pairs) {
|
||||
String[] keyValue = pair.split("=");
|
||||
if (keyValue.length == 2) {
|
||||
params.put(keyValue[0], keyValue[1]);
|
||||
}
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
|
||||
"}";
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(token);
|
||||
} else {
|
||||
// GATEWAY: 실 게이트웨이 토큰 엔드포인트로 forward (token 발급만)
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
headers.put("Content-Type", "application/x-www-form-urlencoded");
|
||||
headers.put("Accept", "application/json");
|
||||
String target = gatewayProperty.baseUrl() + gatewayProperty.tokenPath();
|
||||
String tokenResponse = apiSender.requestPost(target, headers, new HashMap<>(), body);
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(tokenResponse);
|
||||
}
|
||||
String scope = params.getOrDefault("scope", "default");
|
||||
|
||||
String token = "{\n" +
|
||||
" \"access_token\": \"djbank_gw_sample_token\",\n" +
|
||||
" \"token_type\": \"bearer\",\n" +
|
||||
" \"expires_in\": 86400,\n" +
|
||||
" \"scope\": \""+scope +"\",\n" +
|
||||
" \"jti\": \""+ UUID.randomUUID().toString()+"\"\n" +
|
||||
"}";
|
||||
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(token);
|
||||
|
||||
} else {
|
||||
ApiSpecInfoDto apiSpecInfoDto = apiSpecInfoDtoService.selectDetailByURLAndMethod(parseUri(url), httpServletRequest.getMethod());
|
||||
|
||||
// URL/메서드에 해당하는 API 명세가 없으면 404 (NPE 방지)
|
||||
if (apiSpecInfoDto == null) {
|
||||
writeJson(response, HttpServletResponse.SC_NOT_FOUND,
|
||||
"{\"error\":\"해당 URL/메서드의 API 명세를 찾을 수 없습니다.\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
String responseType = apiSpecInfoDto.getResponseType();
|
||||
|
||||
// sample(기본): 저장된 샘플 응답 반환 (실호출 없음)
|
||||
if (responseType == null || responseType.equalsIgnoreCase("sample")) {
|
||||
if (apiSpecInfoDto.getResponseType() == null || apiSpecInfoDto.getResponseType().equals("sample")) {
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(apiSpecInfoDto.getSampleResponse());
|
||||
return;
|
||||
}
|
||||
|
||||
// gw / mock: 실주소로 forward. swagger 서버 표시(DjbTestbedSpecServerRewriter)와 동일하게 맞춘다.
|
||||
// - gw : djb.gateway.base-url + path == original-url 전체 (spec servers[0].url + path)
|
||||
// - mock : ApiSpecInfo.mockUrl (기존 동작)
|
||||
boolean gw = "gw".equalsIgnoreCase(responseType);
|
||||
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String header = headerNames.nextElement();
|
||||
headers.put(header, httpServletRequest.getHeader(header));
|
||||
}
|
||||
headers.remove("original-url");
|
||||
headers.remove("original-api-id");
|
||||
|
||||
String targetUri;
|
||||
Map<String, String[]> paramMap;
|
||||
if (gw) {
|
||||
// original-url 이 이미 (게이트웨이주소 + path + query) 전체이므로 그대로 대상 URL 로 사용.
|
||||
// 프록시 대상 호스트가 바뀌므로 Host 헤더는 제거해 대상 호스트로 자동 설정되게 한다.
|
||||
headers.remove("host");
|
||||
headers.remove("Host");
|
||||
targetUri = url;
|
||||
paramMap = new HashMap<>();
|
||||
} else {
|
||||
// mock: 저장된 mockUrl 로 forward하고, original-url 의 쿼리스트링을 재부착 (기존 동작 유지)
|
||||
targetUri = apiSpecInfoDto.getMockUrl();
|
||||
paramMap = extractQueryParams(url);
|
||||
}
|
||||
String mockUrl = apiSpecInfoDto.getMockUrl();
|
||||
String method = apiSpecInfoDto.getApiMethod();
|
||||
Enumeration<String> headerNames = httpServletRequest.getHeaderNames();
|
||||
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
String responseStr;
|
||||
if ("post".equalsIgnoreCase(apiSpecInfoDto.getApiMethod())) {
|
||||
responseStr = apiSender.requestPost(targetUri, headers, paramMap, readBody(httpServletRequest));
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(targetUri, headers, paramMap);
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
}
|
||||
|
||||
} catch (java.net.SocketTimeoutException e) {
|
||||
// 연결/응답 타임아웃 (djb.gateway.timeout 초과)
|
||||
logger.warn("테스트베드 프록시 타임아웃: {}", e.getMessage());
|
||||
writeJson(response, HttpServletResponse.SC_GATEWAY_TIMEOUT,
|
||||
"{\"error\":\"게이트웨이 응답 시간 초과(timeout)\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (IOException e) {
|
||||
// 연결 실패 등 네트워크 오류
|
||||
logger.error("테스트베드 프록시 호출 실패", e);
|
||||
writeJson(response, HttpServletResponse.SC_BAD_GATEWAY,
|
||||
"{\"error\":\"게이트웨이 호출 실패\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
} catch (Exception e) {
|
||||
// 그 외 예기치 못한 오류도 JSON 으로 반환
|
||||
logger.error("테스트베드 프록시 처리 오류", e);
|
||||
writeJson(response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR,
|
||||
"{\"error\":\"요청 처리 중 오류\",\"detail\":\"" + escapeJson(e.getMessage()) + "\"}");
|
||||
}
|
||||
}
|
||||
|
||||
/** 요청 본문 전체를 문자열로 읽는다. */
|
||||
private String readBody(HttpServletRequest request) throws IOException {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = request.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** original-url 의 쿼리스트링(?a=1&b=2)을 파라미터 맵으로 파싱. */
|
||||
private Map<String, String[]> extractQueryParams(String originalUrl) {
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
String[] parts = originalUrl.split("\\?");
|
||||
if (parts.length > 1) {
|
||||
for (String param : parts[1].split("&")) {
|
||||
String[] kv = param.split("=");
|
||||
if (kv.length > 1) {
|
||||
paramMap.put(kv[0], new String[]{kv[1]});
|
||||
Map<String, String> headers = new HashMap<>();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String header = headerNames.nextElement();
|
||||
headers.put(header, httpServletRequest.getHeader(header));
|
||||
}
|
||||
}
|
||||
}
|
||||
return paramMap;
|
||||
}
|
||||
Map<String, String[]> paramMap = new HashMap<>();
|
||||
|
||||
/** 상태코드 + JSON 본문 응답. */
|
||||
private void writeJson(ServletResponse response, int status, String json) throws IOException {
|
||||
((HttpServletResponse) response).setStatus(status);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(json);
|
||||
}
|
||||
String originalUrl = headers.get("original-url");
|
||||
//sprlit original url with ? get the second part then split with & put into paramMap
|
||||
|
||||
/** JSON 문자열 값에 넣기 위한 최소 escape (예외 메시지 등). */
|
||||
private String escapeJson(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '\\': sb.append("\\\\"); break;
|
||||
case '"': sb.append("\\\""); break;
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
default:
|
||||
if (c < 0x20) {
|
||||
sb.append(String.format("\\u%04x", (int) c));
|
||||
} else {
|
||||
sb.append(c);
|
||||
String[] originalUrlArr = originalUrl.split("\\?");
|
||||
if (originalUrlArr.length > 1) {
|
||||
String[] paramArr = originalUrlArr[1].split("&");
|
||||
for (String param : paramArr) {
|
||||
String[] paramKeyValue = param.split("=");
|
||||
if (paramKeyValue.length > 1) {
|
||||
paramMap.put(paramKeyValue[0], new String[]{paramKeyValue[1]});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
headers.remove("original-url");
|
||||
headers.remove("original-api-id");
|
||||
APISender apiSender = ApplicationContextUtil.getContext().getBean(APISender.class);
|
||||
|
||||
String responseStr = "";
|
||||
if (method.equalsIgnoreCase("post")) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
BufferedReader reader = httpServletRequest.getReader();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
String body = sb.toString();
|
||||
responseStr = apiSender.requestPost(mockUrl, headers, paramMap, body);
|
||||
|
||||
} else {
|
||||
responseStr = apiSender.requestGet(mockUrl, headers, paramMap);
|
||||
}
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().println(responseStr);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -31,21 +31,4 @@ public class ApprovalDTO {
|
||||
private LocalDateTime createdDate;
|
||||
|
||||
private LocalDateTime approvalDate;
|
||||
|
||||
private String expectEndDate; //예상완료일 (yyyyMMdd)
|
||||
|
||||
/** 예상완료일(yyyyMMdd)의 한글 요일. 값이 없거나 형식이 맞지 않으면 빈 문자열. (예: "토") */
|
||||
public String getExpectEndDateDayOfWeek() {
|
||||
if (expectEndDate == null || expectEndDate.length() < 8) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
return java.time.LocalDate
|
||||
.parse(expectEndDate.substring(0, 8), java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"))
|
||||
.getDayOfWeek()
|
||||
.getDisplayName(java.time.format.TextStyle.SHORT, java.util.Locale.KOREAN);
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,6 @@ public interface UserSessionRepository extends JpaRepository<UserSession, String
|
||||
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId AND s.sessionId <> :currentSessionId")
|
||||
int forceLogoutOtherSessions(@Param("loginId") String loginId, @Param("currentSessionId") String currentSessionId);
|
||||
|
||||
@Modifying
|
||||
@Query("UPDATE UserSession s SET s.forceLogout = 'Y' WHERE s.loginId = :loginId")
|
||||
int forceLogoutAllSessions(@Param("loginId") String loginId);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM UserSession s WHERE s.lastAccessTime < :cutoffTime")
|
||||
int deleteExpiredSessions(@Param("cutoffTime") LocalDateTime cutoffTime);
|
||||
|
||||
@@ -74,18 +74,6 @@ public class UserSessionService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 사용자의 모든 세션에 강제 로그아웃 플래그 설정.
|
||||
* 관리자의 권한 변경(위임/회수/소속제외)을 대상 사용자의 활성 세션에 반영하기 위해 사용한다.
|
||||
*/
|
||||
@Transactional
|
||||
public void forceLogoutAllSessions(String loginId) {
|
||||
int count = userSessionRepository.forceLogoutAllSessions(loginId);
|
||||
if (count > 0) {
|
||||
log.info("전체 강제 로그아웃 처리 - loginId: {}, 대상 세션 수: {}", loginId, count);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 세션의 강제 로그아웃 여부 확인
|
||||
*/
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
package com.eactive.apim.portal.apps.user.controller;
|
||||
|
||||
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.dto.*;
|
||||
import com.eactive.apim.portal.apps.user.dto.PasswordChangeRequestDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalOrgRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.UserAgreementDTO;
|
||||
import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import com.eactive.apim.portal.apps.user.facade.OrgRegisterFacade;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserFacade;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -23,19 +33,14 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import javax.servlet.http.Cookie;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Controller
|
||||
@Secured("ROLE_ACCOUNT")
|
||||
@RequiredArgsConstructor
|
||||
@@ -48,7 +53,6 @@ public class AccountController {
|
||||
private final AgreementsFacade agreementsFacade;
|
||||
private final com.eactive.apim.portal.apps.user.facade.AuthFacade authFacade;
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final UserSessionService userSessionService;
|
||||
|
||||
|
||||
@PostMapping("/confirm_password")
|
||||
@@ -101,12 +105,6 @@ public class AccountController {
|
||||
session.removeAttribute("success");
|
||||
session.removeAttribute("redirectUrl");
|
||||
|
||||
// 세션 무효화 전에 DB 세션 레코드를 정리한다.
|
||||
// SecurityContextLogoutHandler 는 HTTP 세션만 invalidate 하고 UserSession DB 레코드는
|
||||
// 남기므로(정상 로그아웃의 PortalLogoutSuccessHandler 경로를 우회), 정리하지 않으면
|
||||
// 재로그인 시 check-duplicate 가 이 옛 세션을 활성으로 보고 "이미 접속중" 으로 오탐한다.
|
||||
userSessionService.removeSession(session.getId());
|
||||
|
||||
new SecurityContextLogoutHandler().logout(request, response,
|
||||
SecurityContextHolder.getContext().getAuthentication());
|
||||
|
||||
@@ -145,7 +143,7 @@ public class AccountController {
|
||||
if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
java.util.Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(currentUser.getMobileNumber()), InvitationStatus.PENDING);
|
||||
currentUser.getMobileNumber(), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
mav.addObject("hasPendingInvitation", true);
|
||||
|
||||
@@ -7,11 +7,9 @@ import com.eactive.apim.portal.apps.user.dto.ValidationResponse;
|
||||
import com.eactive.apim.portal.apps.user.facade.AuthFacade;
|
||||
import com.eactive.apim.portal.apps.user.facade.UserRegisterFacade;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserService;
|
||||
import com.eactive.apim.portal.apps.user.validator.AgreementValidator;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
@@ -65,7 +63,6 @@ public class UserRegisterController {
|
||||
private final UserInvitationRepository userInvitationRepository;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
@GetMapping("/signup")
|
||||
public String showSignupSelection() {
|
||||
@@ -246,11 +243,6 @@ public class UserRegisterController {
|
||||
String decodedToken = decodeInvitationToken(invitationToken);
|
||||
session.setAttribute("decisionToken", decodedToken);
|
||||
|
||||
// 이미 로그인된 상태면 수락 화면으로 직행
|
||||
if (SecurityUtil.isAuthenticated()) {
|
||||
return "redirect:/signup/decision_process";
|
||||
}
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findByToken(decodedToken);
|
||||
|
||||
if (!invitation.isPresent() || invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
|
||||
@@ -289,7 +281,7 @@ public class UserRegisterController {
|
||||
public String showDecisionProcessPage(HttpSession session, Model model) {
|
||||
session.removeAttribute("decisionToken");
|
||||
|
||||
String mobile = PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber());
|
||||
String mobile = SecurityUtil.getPortalAuthenticatedUser().getMobileNumber();
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(mobile, UserInvitationEnums.InvitationStatus.PENDING);
|
||||
|
||||
if (!invitation.isPresent()) {
|
||||
@@ -313,8 +305,6 @@ public class UserRegisterController {
|
||||
model.addAttribute("invitationCode", invitation.get().getToken());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
|
||||
return "apps/register/userDecisionProcess";
|
||||
@@ -333,7 +323,7 @@ public class UserRegisterController {
|
||||
|
||||
agreementValidator.validate(agreement, bindingResult);
|
||||
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(PhoneNumberUtil.normalize(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber()), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
Optional<UserInvitation> invitation = userInvitationRepository.findFirstByInvitationMobileAndStatus(SecurityUtil.getPortalAuthenticatedUser().getMobileNumber(), UserInvitationEnums.InvitationStatus.PENDING);
|
||||
|
||||
if (action.equalsIgnoreCase("accept") && bindingResult.hasErrors()) {
|
||||
|
||||
@@ -344,19 +334,12 @@ public class UserRegisterController {
|
||||
model.addAttribute("orgName", org.get().getOrgName());
|
||||
model.addAttribute("termsOfUse", agreementsFacade.getAgreement("TERMS_OF_USE"));
|
||||
model.addAttribute("privacyCollect", agreementsFacade.getAgreement("PRIVACY_COLLECT"));
|
||||
model.addAttribute("notificationConsent", agreementsFacade.getAgreement("NOTIFICATION_CONSENT"));
|
||||
model.addAttribute("agreementTitle", "법인 회원 전환을 위한 약관 동의");
|
||||
model.addAttribute("registrationType", "corporate");
|
||||
|
||||
return "apps/register/userDecisionProcess";
|
||||
} else {
|
||||
ValidationResponse response = userRegisterFacade.processInvitation(action, invitation.get());
|
||||
|
||||
// 수락 성공 시 재로그인 없이 본인 세션 권한 갱신 (ROLE_USER → ROLE_CORP_USER)
|
||||
if ("accept".equalsIgnoreCase(action) && response.isValid()) {
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
}
|
||||
|
||||
// 초대 처리 완료 후 세션에서 초대 관련 속성 제거
|
||||
session.removeAttribute("pendingInvitation");
|
||||
session.removeAttribute("pendingInvitationToken");
|
||||
|
||||
@@ -7,7 +7,6 @@ import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.common.exception.NotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.DurationParser;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.validator.CellPhoneValidator;
|
||||
@@ -20,8 +19,6 @@ import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.UserStatus;
|
||||
import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
@@ -57,8 +54,6 @@ public class UserManFacade {
|
||||
private final UserLogRepository userLogRepository;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final CellPhoneValidator cellPhoneValidator;
|
||||
private final UserSessionService userSessionService;
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
|
||||
public Page<PortalUserDTO> getUsers(PortalOrgDTO userOrg, Pageable pageable) {
|
||||
return portalUserRepository
|
||||
@@ -183,7 +178,16 @@ public class UserManFacade {
|
||||
* 010-XXXX-XXXX(11자리) / 010-XXX-XXXX(10자리). 형식 불명은 원본 반환(검증에서 차단됨).
|
||||
*/
|
||||
private String normalizeMobile(String raw) {
|
||||
return PhoneNumberUtil.normalize(raw);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
String digits = raw.replaceAll("[^0-9]", "");
|
||||
if (digits.length() == 11) {
|
||||
return digits.substring(0, 3) + "-" + digits.substring(3, 7) + "-" + digits.substring(7);
|
||||
} else if (digits.length() == 10) {
|
||||
return digits.substring(0, 3) + "-" + digits.substring(3, 6) + "-" + digits.substring(6);
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
public String createInvitation(PortalUser sender, String mobile) {
|
||||
@@ -249,10 +253,6 @@ public class UserManFacade {
|
||||
portalUserRepository.save(targetUser);
|
||||
portalUserRepository.save(currentUser);
|
||||
|
||||
// 권한 변경 즉시 반영: 대상(타 세션)은 강제 로그아웃, 본인(현재 세션)은 in-place 재인증
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
portalUserAuthService.reloadCurrentAuthentication();
|
||||
|
||||
MessageRecipient recipient = new MessageRecipient();
|
||||
recipient.setUserId(targetUser.getEmailAddr());
|
||||
recipient.setPhone(targetUser.getMobileNumber());
|
||||
@@ -284,9 +284,6 @@ public class UserManFacade {
|
||||
|
||||
targetUser.setRoleCode(RoleCode.ROLE_CORP_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 권한 회수를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -308,9 +305,6 @@ public class UserManFacade {
|
||||
targetUser.setPortalOrg(null);
|
||||
targetUser.setRoleCode(RoleCode.ROLE_USER);
|
||||
portalUserRepository.save(targetUser);
|
||||
|
||||
// 소속 제외를 대상 사용자의 활성 세션에 반영 (강제 로그아웃 → 재로그인 시 새 권한)
|
||||
userSessionService.forceLogoutAllSessions(targetUser.getLoginId());
|
||||
}
|
||||
|
||||
public void cancelInvitation(PortalAuthenticatedUser user, String invitationId, boolean notifyConsent) {
|
||||
|
||||
@@ -263,7 +263,7 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
|
||||
ValidationResponse response = new ValidationResponse();
|
||||
response.setValid(true);
|
||||
response.setMessage("초대가 성공적으로 수락되었습니다. 법인회원으로 전환되었습니다.");
|
||||
response.setMessage("초대가 성공적으로 수락되었습니다. 로그아웃 후 다시 로그인 해 주세요.");
|
||||
response.setShowPopup(true);
|
||||
response.setPopupType("success");
|
||||
return response;
|
||||
|
||||
@@ -6,7 +6,6 @@ import com.eactive.apim.portal.common.exception.SystemException;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -31,9 +30,7 @@ import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.xerces.impl.dv.util.Base64;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||
@@ -61,46 +58,21 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
// 이메일 소문자 변환 적용 (대소문자 구분 없이 로그인 가능하도록)
|
||||
String normalizedUsername = username != null ? username.toLowerCase() : null;
|
||||
PortalUser portalUser = findByEmailAddr(normalizedUsername);
|
||||
return buildAuthenticatedUser(portalUser);
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
|
||||
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
|
||||
|
||||
return authenticatedUser;
|
||||
|
||||
} catch (UserNotFoundException e) {
|
||||
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DB의 PortalUser 로부터 권한(roleCode + 역할 계층)을 채운 PortalAuthenticatedUser 를 생성한다.
|
||||
* 로그인과 세션 재로드가 동일한 권한 구성을 사용하도록 공통화한다.
|
||||
*/
|
||||
public PortalAuthenticatedUser buildAuthenticatedUser(PortalUser portalUser) {
|
||||
RoleCode userRole = portalUser.getRoleCode() == null ? RoleCode.ROLE_USER : portalUser.getRoleCode();
|
||||
List<String> roles = portalProperties.getPortalSecurity().get(userRole);
|
||||
PortalAuthenticatedUser authenticatedUser = portalUserMapper.portalUserToAuthenticatedUser(portalUser);
|
||||
|
||||
authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(userRole.name()));
|
||||
roles.forEach(role -> authenticatedUser.getAuthorities().add(new SimpleGrantedAuthority(role)));
|
||||
|
||||
return authenticatedUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 로그인한 사용자의 권한을 DB에서 다시 읽어 SecurityContext 의 Authentication 을 교체한다.
|
||||
* 재로그인 없이 본인 세션에 역할 변경(초대 수락, 관리자 위임으로 인한 본인 강등 등)을 즉시 반영한다.
|
||||
*/
|
||||
public void reloadCurrentAuthentication() {
|
||||
PortalAuthenticatedUser current = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
PortalUser fresh = portalUserRepository.findByLoginId(current.getLoginId())
|
||||
.orElseThrow(() -> new UserNotFoundException("사용자를 찾을 수 없습니다."));
|
||||
PortalAuthenticatedUser refreshed = buildAuthenticatedUser(fresh);
|
||||
|
||||
UsernamePasswordAuthenticationToken newAuth =
|
||||
new UsernamePasswordAuthenticationToken(refreshed, null, refreshed.getAuthorities());
|
||||
newAuth.setDetails(refreshed);
|
||||
SecurityContextHolder.getContext().setAuthentication(newAuth);
|
||||
}
|
||||
|
||||
public List<PortalUserDTO> findAllUsersByNameAndMobile(String userName, String mobileNumber) {
|
||||
try {
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -84,7 +83,7 @@ public class PortalUserService {
|
||||
if (mobileNumber == null || mobileNumber.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return portalUserRepository.existsByMobileNumber(PhoneNumberUtil.normalize(mobileNumber));
|
||||
return portalUserRepository.existsByMobileNumber(mobileNumber.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -85,9 +85,9 @@ public class BaseDatasourceConfiguration {
|
||||
persistenceUnit = "gateway";
|
||||
}
|
||||
|
||||
// 개발 환경용
|
||||
// 개발 환경용 - local 프로파일에서는 스키마 검증(ddl-auto) 해제
|
||||
if (env.matchesProfiles("local")) {
|
||||
properties.put("hibernate.hbm2ddl.auto", "validate");
|
||||
properties.put("hibernate.hbm2ddl.auto", "none");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.eactive.apim.portal.apps.session.service.UserSessionService;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitation;
|
||||
import com.eactive.apim.portal.invitation.entity.UserInvitationEnums.InvitationStatus;
|
||||
@@ -96,10 +95,9 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
|
||||
|
||||
// 초대 코드 확인 - ROLE_USER만 확인 (세션에 저장하여 메인 페이지에서 팝업으로 표시)
|
||||
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_USER) {
|
||||
// 휴대폰 형식(하이픈 유무)이 달라도 초대와 매칭되도록 정규화 후 조회
|
||||
Optional<UserInvitation> pendingInvitation =
|
||||
userInvitationRepository.findFirstByInvitationMobileAndStatus(
|
||||
PhoneNumberUtil.normalize(user.getMobileNumber()), InvitationStatus.PENDING);
|
||||
user.getMobileNumber(), InvitationStatus.PENDING);
|
||||
|
||||
if (pendingInvitation.isPresent()) {
|
||||
UserInvitation invitation = pendingInvitation.get();
|
||||
|
||||
@@ -12,10 +12,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
|
||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||
import org.springframework.security.web.csrf.CsrfException;
|
||||
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;
|
||||
import org.springframework.security.web.session.HttpSessionEventPublisher;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
@@ -110,11 +107,6 @@ public class PortalConfigSecurity {
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/api/session/check-duplicate"))
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/migration/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||
// 익명으로 인식되지 못하고 기본 403("권한없음")으로 떨어진다.
|
||||
// CSRF 만료는 권한 문제가 아니라 세션 만료이므로 기존 안내(/login?expired=true)로 보낸다.
|
||||
.exceptionHandling(ex -> ex.accessDeniedHandler(csrfAwareAccessDeniedHandler()))
|
||||
// 중복로그인/유휴 만료는 DB 기반 SessionValidationFilter가 처리 (maximumSessions 미사용)
|
||||
.sessionManagement(session -> session
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
@@ -129,23 +121,6 @@ public class PortalConfigSecurity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF 토큰 누락/불일치는 "권한없음"이 아니라 (대개) 세션 만료다.
|
||||
* 로그인 페이지에 오래 머물러 세션 내 CSRF 토큰이 사라진 경우가 대표적이므로
|
||||
* 기존 세션 만료 안내(login.html의 {@code param.expired})를 재사용해 오해를 막는다.
|
||||
* 그 외 진짜 권한 위반(@Secured 등)은 기본 핸들러에 위임해 403을 유지한다.
|
||||
*/
|
||||
private AccessDeniedHandler csrfAwareAccessDeniedHandler() {
|
||||
AccessDeniedHandlerImpl delegate = new AccessDeniedHandlerImpl();
|
||||
return (request, response, accessDeniedException) -> {
|
||||
if (accessDeniedException instanceof CsrfException) {
|
||||
response.sendRedirect(request.getContextPath() + "/login?expired=true");
|
||||
return;
|
||||
}
|
||||
delegate.handle(request, response, accessDeniedException);
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public HttpSessionEventPublisher httpSessionEventPublisher() {
|
||||
return new HttpSessionEventPublisher();
|
||||
|
||||
@@ -45,12 +45,6 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
@Value("${app.resource-versioning.enabled:true}")
|
||||
private boolean resourceVersioningEnabled;
|
||||
|
||||
// 정적자원 서버측 캐싱(ResourceChain 캐시) 토글(application.yml: app.resource-caching.enabled).
|
||||
// prod 는 이 값을 무시하고 항상 ON 으로 동작한다(isResourceCachingEnabled 참고).
|
||||
// 개발환경에서 OFF 면 sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
|
||||
@Value("${app.resource-caching.enabled:false}")
|
||||
private boolean resourceCachingEnabled;
|
||||
|
||||
public PortalConfigWebDispatcherServlet(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
@@ -103,15 +97,10 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
* {@code @{/css/main.css}} 링크는 {@link #resourceUrlEncodingFilter()} 가 해시 URL 로 치환한다.</p>
|
||||
*/
|
||||
private void addStaticResourceHandler(ResourceHandlerRegistry registry, String pattern, String... locations) {
|
||||
// 캐싱 ON(prod) 이면 브라우저에 1일 캐시를, OFF(dev/local) 면 no-store 를 내려보낸다.
|
||||
// no-store 는 브라우저가 아예 저장하지 않으므로 강제 새로고침 없이 sass/JS 변경이 바로 보인다.
|
||||
CacheControl cacheControl = isResourceCachingEnabled()
|
||||
? CacheControl.maxAge(1, TimeUnit.DAYS)
|
||||
: CacheControl.noStore();
|
||||
ResourceChainRegistration chain = registry.addResourceHandler(pattern)
|
||||
.addResourceLocations(locations)
|
||||
.setCacheControl(cacheControl)
|
||||
.resourceChain(isResourceCachingEnabled());
|
||||
.setCacheControl(CacheControl.maxAge(1, TimeUnit.DAYS))
|
||||
.resourceChain(true);
|
||||
if (isResourceVersioningEnabled()) {
|
||||
chain.addResolver(new VersionResourceResolver().addContentVersionStrategy("/**"));
|
||||
}
|
||||
@@ -129,20 +118,6 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
|
||||
return resourceVersioningEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정적자원 서버측 캐싱(ResourceChain 의 CachingResourceResolver) 활성 여부.
|
||||
* prod 프로파일은 토글과 무관하게 항상 ON, 그 외 프로파일은 {@code app.resource-caching.enabled} 값을 따른다.
|
||||
*
|
||||
* <p>OFF 면 매 요청마다 리소스를 재해석하므로, sass/JS 변경이 서버 재시작 없이 즉시 반영된다.
|
||||
* ({@code spring.web.resources.cache} 는 브라우저 캐시 헤더만 제어할 뿐 이 서버측 캐시와는 무관하다.)</p>
|
||||
*/
|
||||
private boolean isResourceCachingEnabled() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return true;
|
||||
}
|
||||
return resourceCachingEnabled;
|
||||
}
|
||||
|
||||
|
||||
@Bean
|
||||
public MappingJackson2JsonView jsonView() {
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.eactive.apim.portal.config;
|
||||
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.boot.system.ApplicationPid;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Prints a startup banner to stdout once boot is complete.
|
||||
*
|
||||
* <p>Uses {@link System#out} directly (not a logger) so the info is shown even when
|
||||
* the logback console appender is disabled (CONSOLE_LOG_ENABLED=false).
|
||||
*/
|
||||
@Component
|
||||
public class StartupInfoPrinter implements ApplicationListener<ApplicationReadyEvent> {
|
||||
|
||||
@Override
|
||||
public void onApplicationEvent(ApplicationReadyEvent event) {
|
||||
Environment env = event.getApplicationContext().getEnvironment();
|
||||
|
||||
String port = env.getProperty("server.port", "8080");
|
||||
String contextPath = env.getProperty("server.servlet.context-path", "/");
|
||||
if (!contextPath.startsWith("/")) {
|
||||
contextPath = "/" + contextPath;
|
||||
}
|
||||
String url = "http://localhost:" + port + contextPath;
|
||||
|
||||
String[] active = env.getActiveProfiles();
|
||||
String profile = active.length == 0
|
||||
? String.join(", ", env.getDefaultProfiles())
|
||||
: String.join(", ", active);
|
||||
|
||||
String instanceName = env.getProperty("inst.Name", System.getProperty("inst.Name", "unknown"));
|
||||
String pid = new ApplicationPid().toString();
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(System.lineSeparator());
|
||||
sb.append("=====================================================").append(System.lineSeparator());
|
||||
sb.append(" EAPIM Portal - startup complete").append(System.lineSeparator());
|
||||
sb.append("=====================================================").append(System.lineSeparator());
|
||||
sb.append(" URL : ").append(url).append(System.lineSeparator());
|
||||
sb.append(" Spring profile : ").append(profile).append(System.lineSeparator());
|
||||
sb.append(" Instance name : ").append(instanceName).append(System.lineSeparator());
|
||||
sb.append(" PID : ").append(pid).append(System.lineSeparator());
|
||||
sb.append("=====================================================").append(System.lineSeparator());
|
||||
|
||||
System.out.println(sb);
|
||||
System.out.flush();
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.advice;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedAuthController;
|
||||
import com.eactive.apim.portal.djb.testbed.controller.DjbTestbedSpecController;
|
||||
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 테스트베드 두 컨트롤러 한정 예외 → JSON 변환.
|
||||
* (qna 로컬 핸들러와 동일한 {@code {code, message}} 포맷.)
|
||||
*/
|
||||
@RestControllerAdvice(assignableTypes = {DjbTestbedAuthController.class, DjbTestbedSpecController.class})
|
||||
public class DjbTestbedExceptionHandler {
|
||||
|
||||
@ExceptionHandler(DjbUnsupportedAuthTypeException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleUnsupportedAuthType(DjbUnsupportedAuthTypeException ex) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("code", "UNSUPPORTED_AUTHTYPE");
|
||||
body.put("message", ex.getMessage());
|
||||
body.put("authtype", ex.getAuthtype());
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException ex) {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("code", "FORBIDDEN");
|
||||
body.put("message", ex.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.config;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* 테스트베드 게이트웨이 설정을 {@code PTL_PROPERTY}(그룹 {@code Portal}, key prefix {@code djb.gateway.})
|
||||
* 에서 읽는 얇은 접근자. 별도 property-injection 인프라 대신 기존
|
||||
* {@link PortalPropertyService#getOrCreateProperty} 를 직접 사용한다(사용자 지시).
|
||||
*
|
||||
* <p>{@code getOrCreateProperty} 는 DB row 미존재 시 default 로 INSERT(그룹 존재 시)하지만
|
||||
* <em>공백 값 보정은 하지 않으므로</em>, 여기 {@link #resolve} 에서 {@code isBlank → default} 폴백을 둔다.
|
||||
*
|
||||
* <p>비-Spring 컴포넌트인 {@code ApiTesterFilter}(@WebFilter)는
|
||||
* {@code ApplicationContextUtil.getContext().getBean(DjbTestbedGatewayProperty.class)} 로 접근한다.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedGatewayProperty {
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
private static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_BASE_URL = "djb.gateway.base-url";
|
||||
public static final String KEY_TOKEN_PATH = "djb.gateway.token-path";
|
||||
public static final String KEY_API_KEY_HEADER = "djb.gateway.api-key-header";
|
||||
public static final String KEY_OAUTH_HEADER = "djb.gateway.oauth-token-header";
|
||||
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_TOKEN_USE_PROXY = "djb.gateway.token-use-proxy";
|
||||
|
||||
public static final String DEFAULT_BASE_URL = "PortalMock";
|
||||
public static final String DEFAULT_TIMEOUT_SEC = "10";
|
||||
public static final String DEFAULT_USE_PROXY = "true";
|
||||
public static final String DEFAULT_TOKEN_USE_PROXY = "true";
|
||||
private static final int TIMEOUT_FALLBACK_MS = 10_000;
|
||||
/** GW 모드 실제 토큰 엔드포인트 경로 (DJERP_4000 OAuth 발급가이드 v0.2). */
|
||||
public static final String DEFAULT_TOKEN_PATH = "/dj/oauth/token";
|
||||
public static final String DEFAULT_API_KEY_HEADER = "X-DJB-API-Key";
|
||||
/** 게이트웨이 API 호출 시 액세스 토큰 헤더명 (가이드 v0.2: Authorization → X-AUTH-TOKEN). */
|
||||
public static final String DEFAULT_OAUTH_HEADER = "X-AUTH-TOKEN";
|
||||
|
||||
/** PortalMock 모드에서 사용하는 포털 기존 mock 토큰 경로. */
|
||||
public static final String PORTAL_MOCK_TOKEN_PATH = "/api/v1/oauth/token";
|
||||
|
||||
public String baseUrl() {
|
||||
return resolve(KEY_BASE_URL, DEFAULT_BASE_URL,
|
||||
"GW Base URL. 문자열 \"PortalMock\" 이면 ApiTesterFilter 가 mock 토큰 반환");
|
||||
}
|
||||
|
||||
public String tokenPath() {
|
||||
return resolve(KEY_TOKEN_PATH, DEFAULT_TOKEN_PATH,
|
||||
"OAuth 토큰 발급 경로 (GW 모드에서만 사용)");
|
||||
}
|
||||
|
||||
public String apiKeyHeader() {
|
||||
return resolve(KEY_API_KEY_HEADER, DEFAULT_API_KEY_HEADER,
|
||||
"API Key 전달 헤더명");
|
||||
}
|
||||
|
||||
public String oauthHeader() {
|
||||
return resolve(KEY_OAUTH_HEADER, DEFAULT_OAUTH_HEADER,
|
||||
"OAuth 액세스 토큰 전달 헤더명 (Bearer 토큰)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 테스트베드 API 호출 시 서버 프록시({@code /api/call-api}) 사용 여부.
|
||||
* {@code false} 이면 브라우저에서 대상 주소로 직접 호출(CORS 허용 필요). 기본 true.
|
||||
*/
|
||||
public boolean useProxy() {
|
||||
return parseBool(resolve(KEY_USE_PROXY, DEFAULT_USE_PROXY,
|
||||
"테스트베드 API 호출 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토큰 발급 시 서버 프록시({@code /api/call-api}) 사용 여부. API 호출 프록시와 별개로 설정.
|
||||
* {@code false} 이면 브라우저에서 토큰 URL 로 직접 호출(CORS 허용 필요). 기본 true.
|
||||
* (mock 모드는 포탈 자체 mock 토큰이라 프록시 유지가 자연스럽고, 실 GW 는 직접호출로 뺄 수 있음.)
|
||||
*/
|
||||
public boolean tokenUseProxy() {
|
||||
return parseBool(resolve(KEY_TOKEN_USE_PROXY, DEFAULT_TOKEN_USE_PROXY,
|
||||
"테스트베드 토큰 발급 시 서버 프록시(/api/call-api) 사용 여부(true/false). false 이면 브라우저에서 직접 호출."), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로퍼티 값을 boolean 으로 해석. 레거시 {@code Y/N} 값도 자동 변환한다.
|
||||
* {@code true}/{@code Y} → true, {@code false}/{@code N} → false, 그 외/null → {@code def}.
|
||||
*/
|
||||
private static boolean parseBool(String v, boolean def) {
|
||||
if (v == null) {
|
||||
return def;
|
||||
}
|
||||
String s = v.trim();
|
||||
if (s.equalsIgnoreCase("true") || s.equalsIgnoreCase("Y")) {
|
||||
return true;
|
||||
}
|
||||
if (s.equalsIgnoreCase("false") || s.equalsIgnoreCase("N")) {
|
||||
return false;
|
||||
}
|
||||
return def;
|
||||
}
|
||||
|
||||
/** 테스트베드 프록시 연결/응답 타임아웃(ms). 미설정/파싱 실패 시 10초. (단위 저장: 초) */
|
||||
public int timeoutMillis() {
|
||||
try {
|
||||
int sec = Integer.parseInt(resolve(KEY_TIMEOUT_SEC, DEFAULT_TIMEOUT_SEC,
|
||||
"테스트베드 프록시 연결/응답 타임아웃(초)"));
|
||||
return sec > 0 ? sec * 1000 : TIMEOUT_FALLBACK_MS;
|
||||
} catch (Exception e) {
|
||||
return TIMEOUT_FALLBACK_MS;
|
||||
}
|
||||
}
|
||||
|
||||
public DjbGatewayMode resolveGatewayMode() {
|
||||
return DEFAULT_BASE_URL.equalsIgnoreCase(baseUrl()) ? DjbGatewayMode.PORTAL_MOCK : DjbGatewayMode.GATEWAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth 토큰 발급 URL.
|
||||
* PortalMock → {@code portalOrigin + /api/v1/oauth/token} (기존 mock 필터가 가로챔),
|
||||
* GATEWAY → {@code baseUrl + tokenPath}.
|
||||
*/
|
||||
public String resolveTokenUrl(String portalOrigin) {
|
||||
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
|
||||
return stripTrailingSlash(portalOrigin) + PORTAL_MOCK_TOKEN_PATH;
|
||||
}
|
||||
return stripTrailingSlash(baseUrl()) + tokenPath();
|
||||
}
|
||||
|
||||
/**
|
||||
* GW 응답유형(spec responseType=gw)에서 사용할 API 호출 base URL.
|
||||
* PortalMock → 요청 포탈 origin(mock 필터가 프록시), GATEWAY → {@code baseUrl}.
|
||||
* (별도 {@code swagger.gw.address} 프로퍼티를 두지 않고 이 값을 단일 기준으로 사용한다.)
|
||||
*/
|
||||
public String resolveApiBaseUrl(String portalOrigin) {
|
||||
if (resolveGatewayMode() == DjbGatewayMode.PORTAL_MOCK) {
|
||||
return stripTrailingSlash(portalOrigin);
|
||||
}
|
||||
return stripTrailingSlash(baseUrl());
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
String value = portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
return StringUtils.hasText(value) ? value.trim() : defaultValue;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) {
|
||||
return "";
|
||||
}
|
||||
return s.endsWith("/") ? s.substring(0, s.length() - 1) : s;
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.controller;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 테스트베드 인증 컨텍스트/시크릿 REST.
|
||||
* 컨트롤러 진입은 인증 없이 허용하되(비대상자도 context 를 받아야 함),
|
||||
* 실제 인증/역할/소유권 검증은 {@link DjbTestbedAuthService} 가 수행한다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/djb/testbed/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedAuthController {
|
||||
|
||||
private final DjbTestbedAuthService authService;
|
||||
|
||||
@GetMapping("/context")
|
||||
public ResponseEntity<DjbTestbedContextDto> context(HttpServletRequest request) {
|
||||
return ResponseEntity.ok(authService.buildContext(resolveOrigin(request)));
|
||||
}
|
||||
|
||||
@GetMapping("/credentials/{clientId}/secret")
|
||||
public ResponseEntity<DjbCredentialSecretDto> credentialSecret(@PathVariable String clientId,
|
||||
@RequestParam String apiId) {
|
||||
DjbCredentialSecretDto secret = authService.getCredentialSecret(clientId, apiId);
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CACHE_CONTROL, "no-store")
|
||||
.header(HttpHeaders.PRAGMA, "no-cache")
|
||||
.body(secret);
|
||||
}
|
||||
|
||||
/** scheme://host[:port] (기본 포트는 생략). PortalMock 토큰 URL 구성에 사용. */
|
||||
private String resolveOrigin(HttpServletRequest request) {
|
||||
String scheme = request.getScheme();
|
||||
String host = request.getServerName();
|
||||
int port = request.getServerPort();
|
||||
boolean defaultPort = ("http".equals(scheme) && port == 80) || ("https".equals(scheme) && port == 443);
|
||||
return scheme + "://" + host + (defaultPort ? "" : ":" + port);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.controller;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.apispec.service.ApiSpecInfoService;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbSwaggerSpecEnricher;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedAuthService;
|
||||
import com.eactive.apim.portal.djb.testbed.service.DjbTestbedSpecServerRewriter;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.FileCopyUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* testbed spec(swagger.json/yaml)에 AUTHTYPE 기반 securityScheme 를 주입하고,
|
||||
* 서버 sentinel({@link DjbTestbedSpecServerRewriter#SERVER_SENTINEL})을 API SPEC 설정(responseType)에
|
||||
* 따른 실주소로 치환해 반환한다.
|
||||
* {@code default-token-api-spec} 은 클래스패스 기본 spec 을 그대로 반환(auth enrich 대상 외).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/djb/testbed/apis")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedSpecController {
|
||||
|
||||
private final ApiSpecInfoService apiSpecInfoService;
|
||||
private final DjbTestbedAuthService authService;
|
||||
private final DjbSwaggerSpecEnricher enricher;
|
||||
private final DjbTestbedSpecServerRewriter serverRewriter;
|
||||
|
||||
private static final String DEFAULT_TOKEN_API_ID = "default-token-api-spec";
|
||||
private static final String DEFAULT_SPEC_PATH = "swagger/default-token-api-spec.json";
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.json", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<String> swaggerWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(json);
|
||||
}
|
||||
|
||||
@GetMapping(value = "/{id}/swagger.yaml", produces = "application/x-yaml")
|
||||
public ResponseEntity<String> swaggerYamlWithAuth(@PathVariable String id, HttpServletRequest request) throws IOException {
|
||||
String json = buildSpecJson(id, request);
|
||||
return json == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(serverRewriter.toYaml(json));
|
||||
}
|
||||
|
||||
/** default 토큰 spec 또는 저장 spec(auth enrich + 서버 sentinel 치환)을 JSON 으로 반환. 없으면 null. */
|
||||
private String buildSpecJson(String id, HttpServletRequest request) throws IOException {
|
||||
if (DEFAULT_TOKEN_API_ID.equals(id)) {
|
||||
Resource resource = new ClassPathResource(DEFAULT_SPEC_PATH);
|
||||
String content = new String(FileCopyUtils.copyToByteArray(resource.getInputStream()), StandardCharsets.UTF_8);
|
||||
return serverRewriter.rewriteServer(content, null, request);
|
||||
}
|
||||
|
||||
Optional<ApiSpecInfo> spec = apiSpecInfoService.findById(id);
|
||||
if (!spec.isPresent() || !StringUtils.hasText(spec.get().getTestbedSpec())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
DjbAuthType authType = authService.resolveAuthType(id);
|
||||
String enriched = enricher.enrich(spec.get().getTestbedSpec(), authType);
|
||||
return serverRewriter.rewriteServer(enriched, spec.get(), request);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 테스트베드 앱 셀렉트에 노출할 앱(PTL_CREDENTIAL) 1건. 시크릿은 포함하지 않는다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbCredentialOptionDto {
|
||||
|
||||
/** PTL_CREDENTIAL.clientid */
|
||||
private String clientId;
|
||||
|
||||
/** 사용자에게 보여줄 앱명(PTL_CREDENTIAL.clientname) */
|
||||
private String clientName;
|
||||
|
||||
/** "0": 차단, "1": 정상 */
|
||||
private String appStatus;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 선택한 앱의 시크릿 단건 응답(캐시 금지 · 로그 마스킹 대상).
|
||||
* OAUTH 시 {@code clientSecret} 은 client_secret, API_KEY 시 API Key 값이다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbCredentialSecretDto {
|
||||
|
||||
private String clientId;
|
||||
|
||||
private String clientSecret;
|
||||
|
||||
private DjbAuthType authType;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.dto;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbGatewayMode;
|
||||
import java.util.List;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* {@code GET /djb/testbed/auth/context} 응답. 시크릿은 포함하지 않는다.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class DjbTestbedContextDto {
|
||||
|
||||
/** 인증 정보 제공 대상자 여부 */
|
||||
private boolean eligible;
|
||||
|
||||
/** 비대상 사유 코드: ANONYMOUS / INDIVIDUAL_USER / NO_CREDENTIAL (대상자면 null) */
|
||||
private String reason;
|
||||
|
||||
/** 사용자가 선택할 수 있는 앱 목록 */
|
||||
private List<DjbCredentialOptionDto> credentials;
|
||||
|
||||
/** 게이트웨이 정보(시크릿 제외) */
|
||||
private GatewayInfo gateway;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public static class GatewayInfo {
|
||||
private DjbGatewayMode mode;
|
||||
private String baseUrl;
|
||||
private String tokenUrl;
|
||||
private String apiKeyHeader;
|
||||
private String oauthTokenHeader;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.enums;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.exception.DjbUnsupportedAuthTypeException;
|
||||
|
||||
/**
|
||||
* 테스트베드가 지원하는 인증 체계. {@code TSEAIHE01.AUTHTYPE}(소문자 저장) 값을 매핑한다.
|
||||
* <ul>
|
||||
* <li>{@code oauth}/{@code oauth2}/{@code ca} → {@link #OAUTH}</li>
|
||||
* <li>{@code api_key}/{@code apikey} → {@link #API_KEY}</li>
|
||||
* <li>{@code none}/{@code no}/{@code noauth} → {@link #NONE}(인증 없이 호출 — 앱 선택 불필요)</li>
|
||||
* <li>{@code null}(EAIMessage 미존재) 및 그 외 → {@link DjbUnsupportedAuthTypeException}</li>
|
||||
* </ul>
|
||||
* ({@code ca} 는 게이트웨이 레거시 매퍼 {@code ObpApiService.mapAuthType} 이 OAUTH2 로 취급하는 값.)
|
||||
*/
|
||||
public enum DjbAuthType {
|
||||
OAUTH,
|
||||
API_KEY,
|
||||
NONE;
|
||||
|
||||
public static DjbAuthType fromAuthtype(String authtype) {
|
||||
// null(EAIMessage 미존재)은 데이터 문제 → 종전대로 예외. "none" 은 인증 없는 API.
|
||||
if (authtype == null) {
|
||||
throw new DjbUnsupportedAuthTypeException("null");
|
||||
}
|
||||
String normalized = authtype.trim().toLowerCase();
|
||||
switch (normalized) {
|
||||
case "oauth":
|
||||
case "oauth2":
|
||||
case "ca":
|
||||
return OAUTH;
|
||||
case "api_key":
|
||||
case "apikey":
|
||||
return API_KEY;
|
||||
case "none":
|
||||
case "no":
|
||||
case "noauth":
|
||||
return NONE;
|
||||
default:
|
||||
throw new DjbUnsupportedAuthTypeException(authtype);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.enums;
|
||||
|
||||
/**
|
||||
* 테스트베드 호출이 향하는 대상 모드.
|
||||
* <ul>
|
||||
* <li>{@code PORTAL_MOCK} — {@code djb.gateway.base-url} 이 문자열 "PortalMock" 인 경우.
|
||||
* {@code ApiTesterFilter} 가 mock 토큰/샘플 응답을 반환한다.</li>
|
||||
* <li>{@code GATEWAY} — 그 외(실제 게이트웨이 URL). 토큰 발급 요청을 실 게이트웨이로 forward.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum DjbGatewayMode {
|
||||
PORTAL_MOCK,
|
||||
GATEWAY
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.exception;
|
||||
|
||||
/**
|
||||
* TSEAIHE01.AUTHTYPE 값이 테스트베드가 지원하는 인증 체계(oauth/api_key)로
|
||||
* 매핑되지 않을 때 발생. {@link com.eactive.apim.portal.djb.testbed.advice.DjbTestbedExceptionHandler}
|
||||
* 에서 400 응답으로 변환된다.
|
||||
*/
|
||||
public class DjbUnsupportedAuthTypeException extends RuntimeException {
|
||||
|
||||
private final String authtype;
|
||||
|
||||
public DjbUnsupportedAuthTypeException(String authtype) {
|
||||
super("지원하지 않는 인증 체계: " + authtype);
|
||||
this.authtype = authtype;
|
||||
}
|
||||
|
||||
public String getAuthtype() {
|
||||
return authtype;
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* testbed spec(JSON)에 인증 체계를 주입한다.
|
||||
*
|
||||
* <p>현재 데이터는 <b>OpenAPI 3.0</b>(auto-gen {@code "openapi":"3.0.0"})이므로
|
||||
* {@code components.securitySchemes} + 글로벌 {@code security} 에 주입한다.
|
||||
* (원본이 Swagger 2.0 이면 {@code securityDefinitions} 로 폴백.)
|
||||
*
|
||||
* <p>게이트웨이가 OAuth 액세스 토큰을 표준 {@code Authorization} 이 아니라 커스텀 헤더
|
||||
* {@code X-AUTH-TOKEN: Bearer ...} 로 받으므로(DJERP_4000 v0.2), OAuth 도 API_KEY 와 동일하게
|
||||
* <b>apiKey-in-header</b> 스킴으로 모델링한다(헤더명만 상이). 토큰 발급은 템플릿 JS가
|
||||
* {@code /api/call-api} 경유로 수행해 값을 {@code "Bearer <token>"} 으로 authorize 한다.
|
||||
*
|
||||
* <p>원본의 {@code paths}, {@code x-*} 확장, 기타 {@code components} 는 트리 조작으로 보존한다
|
||||
* (문자열 치환 금지).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DjbSwaggerSpecEnricher {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
public static final String OAUTH_SCHEME = "djbOAuth";
|
||||
public static final String API_KEY_SCHEME = "djbApiKey";
|
||||
|
||||
public String enrich(String specJson, DjbAuthType authType) throws JsonProcessingException {
|
||||
JsonNode parsed = objectMapper.readTree(specJson);
|
||||
if (!parsed.isObject()) {
|
||||
// 예상 밖 형태면 원본 유지(멱등)
|
||||
return specJson;
|
||||
}
|
||||
ObjectNode root = (ObjectNode) parsed;
|
||||
|
||||
// NONE: 인증 없이 호출 — 글로벌 security / securityScheme 흔적을 제거해
|
||||
// Swagger UI 의 'Authorize' 버튼이 뜨지 않게 한다. (저장 spec 에 빈 securitySchemes 가
|
||||
// 남아 있으면 UI 가 빈 인증 모달 버튼을 렌더하므로 정리한다.)
|
||||
if (authType == DjbAuthType.NONE) {
|
||||
return stripSecurity(root);
|
||||
}
|
||||
|
||||
String schemeName = (authType == DjbAuthType.OAUTH) ? OAUTH_SCHEME : API_KEY_SCHEME;
|
||||
ObjectNode scheme = buildApiKeyScheme(authType);
|
||||
|
||||
boolean swagger2 = root.has("swagger") && !root.has("openapi");
|
||||
if (swagger2) {
|
||||
getOrCreateObject(root, "securityDefinitions").set(schemeName, scheme);
|
||||
} else {
|
||||
ObjectNode components = getOrCreateObject(root, "components");
|
||||
getOrCreateObject(components, "securitySchemes").set(schemeName, scheme);
|
||||
}
|
||||
|
||||
// 글로벌 security 요구사항 (apiKey 스킴은 빈 스코프 배열)
|
||||
ArrayNode security = objectMapper.createArrayNode();
|
||||
ObjectNode requirement = objectMapper.createObjectNode();
|
||||
requirement.set(schemeName, objectMapper.createArrayNode());
|
||||
security.add(requirement);
|
||||
root.set("security", security);
|
||||
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
/** {@code { "type":"apiKey", "in":"header", "name":"<header>" }} — 2.0/3.0 동일 형태. */
|
||||
private ObjectNode buildApiKeyScheme(DjbAuthType authType) {
|
||||
String headerName = (authType == DjbAuthType.OAUTH)
|
||||
? gatewayProperty.oauthHeader()
|
||||
: gatewayProperty.apiKeyHeader();
|
||||
ObjectNode scheme = objectMapper.createObjectNode();
|
||||
scheme.put("type", "apiKey");
|
||||
scheme.put("in", "header");
|
||||
scheme.put("name", headerName);
|
||||
return scheme;
|
||||
}
|
||||
|
||||
/** NONE 용: 글로벌 {@code security} / {@code components.securitySchemes} / {@code securityDefinitions} 제거. */
|
||||
private String stripSecurity(ObjectNode root) throws JsonProcessingException {
|
||||
root.remove("security");
|
||||
root.remove("securityDefinitions"); // swagger 2.0
|
||||
JsonNode components = root.get("components");
|
||||
if (components != null && components.isObject()) {
|
||||
((ObjectNode) components).remove("securitySchemes");
|
||||
}
|
||||
return objectMapper.writeValueAsString(root);
|
||||
}
|
||||
|
||||
private ObjectNode getOrCreateObject(ObjectNode parent, String field) {
|
||||
JsonNode node = parent.get(field);
|
||||
if (node != null && node.isObject()) {
|
||||
return (ObjectNode) node;
|
||||
}
|
||||
ObjectNode created = objectMapper.createObjectNode();
|
||||
parent.set(field, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.gateway.data.eaimessage.EAIMessageRepository;
|
||||
import com.eactive.apim.portal.app.entity.Credential;
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialOptionDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbCredentialSecretDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto;
|
||||
import com.eactive.apim.portal.djb.testbed.dto.DjbTestbedContextDto.GatewayInfo;
|
||||
import com.eactive.apim.portal.djb.testbed.enums.DjbAuthType;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
|
||||
import com.eactive.eai.data.entity.onl.message.EAIMessageEntity;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 테스트베드 인증 컨텍스트 구성 · 앱 시크릿 조회 · authtype 매핑.
|
||||
* 인증/역할/소유권 검증은 이 서비스에서 수행한다({@code SecurityFilterChain} 은 URL 인가 블록이 없음).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DjbTestbedAuthService {
|
||||
|
||||
private final CredentialRepository credentialRepository;
|
||||
private final EAIMessageRepository eaiMessageRepository;
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
|
||||
private static final String APP_STATUS_ACTIVE = "1";
|
||||
|
||||
/**
|
||||
* 비대상 사유: {@code ANONYMOUS}(비로그인) / {@code INDIVIDUAL_USER}(ROLE_USER) /
|
||||
* {@code NO_CREDENTIAL}(appstatus='1' 앱 0건). 하나라도 해당하면 {@code eligible=false}.
|
||||
*/
|
||||
public DjbTestbedContextDto buildContext(String portalOrigin) {
|
||||
GatewayInfo gateway = buildGatewayInfo(portalOrigin);
|
||||
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
return notEligible("ANONYMOUS", gateway);
|
||||
}
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
return notEligible("INDIVIDUAL_USER", gateway);
|
||||
}
|
||||
|
||||
String orgId = SecurityUtil.getUserOrg().getId();
|
||||
List<DjbCredentialOptionDto> options = credentialRepository.findAllByOrgid(orgId).stream()
|
||||
.filter(c -> APP_STATUS_ACTIVE.equals(c.getAppstatus()))
|
||||
.map(this::toOption)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (options.isEmpty()) {
|
||||
return notEligible("NO_CREDENTIAL", gateway);
|
||||
}
|
||||
|
||||
return DjbTestbedContextDto.builder()
|
||||
.eligible(true)
|
||||
.reason(null)
|
||||
.credentials(options)
|
||||
.gateway(gateway)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 앱의 시크릿 단건. 로그인 + 비-ROLE_USER + 본인 소유(orgId 일치) 검증 후 반환.
|
||||
* 소유 불일치/비대상은 {@link AccessDeniedException}(→ 403).
|
||||
*/
|
||||
public DjbCredentialSecretDto getCredentialSecret(String clientId, String apiId) {
|
||||
if (!SecurityUtil.isAuthenticated()) {
|
||||
throw new AccessDeniedException("로그인이 필요합니다.");
|
||||
}
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
if (user.getRoleCode() == RoleCode.ROLE_USER) {
|
||||
throw new AccessDeniedException("개인사용자는 이용할 수 없습니다.");
|
||||
}
|
||||
|
||||
String orgId = SecurityUtil.getUserOrg().getId();
|
||||
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||
.orElseThrow(() -> new AccessDeniedException("본인 소유의 앱이 아닙니다."));
|
||||
|
||||
DjbAuthType authType = resolveAuthType(apiId);
|
||||
|
||||
return DjbCredentialSecretDto.builder()
|
||||
.clientId(credential.getClientid())
|
||||
.clientSecret(credential.getClientsecret())
|
||||
.authType(authType)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* apiId(=TSEAIHE01.eaisvcname, admin 입력 컨벤션)로 authtype 조회 후 매핑.
|
||||
* 미매핑/미존재는 {@code DjbUnsupportedAuthTypeException}(→ 400).
|
||||
*/
|
||||
public DjbAuthType resolveAuthType(String apiId) {
|
||||
String authtype = eaiMessageRepository.findById(apiId)
|
||||
.map(EAIMessageEntity::getAuthtype)
|
||||
.orElse(null);
|
||||
return DjbAuthType.fromAuthtype(authtype);
|
||||
}
|
||||
|
||||
private DjbCredentialOptionDto toOption(Credential c) {
|
||||
return DjbCredentialOptionDto.builder()
|
||||
.clientId(c.getClientid())
|
||||
.clientName(c.getClientname())
|
||||
.appStatus(c.getAppstatus())
|
||||
.build();
|
||||
}
|
||||
|
||||
private GatewayInfo buildGatewayInfo(String portalOrigin) {
|
||||
return GatewayInfo.builder()
|
||||
.mode(gatewayProperty.resolveGatewayMode())
|
||||
.baseUrl(gatewayProperty.baseUrl())
|
||||
.tokenUrl(gatewayProperty.resolveTokenUrl(portalOrigin))
|
||||
.apiKeyHeader(gatewayProperty.apiKeyHeader())
|
||||
.oauthTokenHeader(gatewayProperty.oauthHeader())
|
||||
.build();
|
||||
}
|
||||
|
||||
private DjbTestbedContextDto notEligible(String reason, GatewayInfo gateway) {
|
||||
return DjbTestbedContextDto.builder()
|
||||
.eligible(false)
|
||||
.reason(reason)
|
||||
.credentials(Collections.emptyList())
|
||||
.gateway(gateway)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.testbed.service;
|
||||
|
||||
import com.eactive.apim.portal.apispec.entity.ApiSpecInfo;
|
||||
import com.eactive.apim.portal.djb.testbed.config.DjbTestbedGatewayProperty;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.yaml.snakeyaml.DumperOptions;
|
||||
import org.yaml.snakeyaml.Yaml;
|
||||
|
||||
/**
|
||||
* testbed spec(JSON)에 baking 된 서버 sentinel({@value #SERVER_SENTINEL})을
|
||||
* API SPEC 설정(responseType)에 따른 실주소로 치환한다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>gw : {@link DjbTestbedGatewayProperty#resolveApiBaseUrl}(GW base URL, 단일 기준)</li>
|
||||
* <li>mock : {@code ApiSpecInfo.mockUrl}</li>
|
||||
* <li>sample(기본) : 요청 포탈 origin(scheme://host)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>spec 의 서버주소는 Swagger UI path 표시 + cURL 스니펫용이다(실호출은 {@code /api/call-api} 프록시).
|
||||
* admin(app.js buildOpenApiSpec)은 env별 실주소를 저장시 굳히지 않고 sentinel 만 path 앞에 baking 하며,
|
||||
* 실제 치환은 이 서비스가 spec 제공 시점에 수행한다. 또한 동일 spec 을 YAML 로 변환 제공한다.
|
||||
*
|
||||
* <p>sentinel 문자열은 유일하므로 문자열 치환으로 처리한다(없으면 원본 유지, 멱등).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DjbTestbedSpecServerRewriter {
|
||||
|
||||
/** admin(app.js buildOpenApiSpec)이 path 앞에 baking 하는 고정 서버 sentinel. */
|
||||
public static final String SERVER_SENTINEL = "http://swagger-server-url";
|
||||
|
||||
private final DjbTestbedGatewayProperty gatewayProperty;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/** sentinel → 응답유형별 실주소 치환한 spec JSON 반환. sentinel 없거나 실주소 미확정 시 원본 유지. */
|
||||
public String rewriteServer(String specJson, ApiSpecInfo spec, HttpServletRequest request) {
|
||||
if (specJson == null || !specJson.contains(SERVER_SENTINEL)) {
|
||||
return specJson;
|
||||
}
|
||||
String base = stripTrailingSlash(resolveBase(spec, request));
|
||||
if (!StringUtils.hasText(base)) {
|
||||
return specJson; // 실주소 미확정 시 sentinel 유지
|
||||
}
|
||||
return specJson.replace(SERVER_SENTINEL, base);
|
||||
}
|
||||
|
||||
/** spec JSON → YAML 문자열. 변환 실패 시 JSON 원본 반환. */
|
||||
public String toYaml(String specJson) {
|
||||
try {
|
||||
Object tree = objectMapper.readValue(specJson, Object.class);
|
||||
DumperOptions opts = new DumperOptions();
|
||||
opts.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK);
|
||||
opts.setPrettyFlow(true);
|
||||
return new Yaml(opts).dump(tree);
|
||||
} catch (Exception e) {
|
||||
log.error("spec YAML 변환 실패, JSON 원본 반환", e);
|
||||
return specJson;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveBase(ApiSpecInfo spec, HttpServletRequest request) {
|
||||
String rt = (spec == null || !StringUtils.hasText(spec.getResponseType()))
|
||||
? "sample" : spec.getResponseType().trim();
|
||||
if ("gw".equalsIgnoreCase(rt)) {
|
||||
return gatewayProperty.resolveApiBaseUrl(originOf(request));
|
||||
}
|
||||
if ("mock".equalsIgnoreCase(rt)) {
|
||||
String mock = (spec == null) ? null : spec.getMockUrl();
|
||||
return StringUtils.hasText(mock) ? mock.trim() : originOf(request);
|
||||
}
|
||||
// sample(기본): 포탈 origin
|
||||
return originOf(request);
|
||||
}
|
||||
|
||||
/** 요청 기준 포탈 origin(scheme://host[:port]) — 리버스 프록시 X-Forwarded-* 우선. */
|
||||
public static String originOf(HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
return "";
|
||||
}
|
||||
String scheme = firstNonBlank(request.getHeader("X-Forwarded-Proto"), request.getScheme());
|
||||
String host = firstNonBlank(request.getHeader("X-Forwarded-Host"), request.getHeader("Host"));
|
||||
if (StringUtils.hasText(host)) {
|
||||
return scheme + "://" + host.trim();
|
||||
}
|
||||
int port = request.getServerPort();
|
||||
String hostPort = request.getServerName() + ((port == 80 || port == 443) ? "" : ":" + port);
|
||||
return scheme + "://" + hostPort;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String a, String b) {
|
||||
return StringUtils.hasText(a) ? a.trim() : b;
|
||||
}
|
||||
|
||||
private static String stripTrailingSlash(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim();
|
||||
while (t.endsWith("/")) {
|
||||
t = t.substring(0, t.length() - 1);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
enabled: true
|
||||
config:
|
||||
activate:
|
||||
on-profile: dev
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
enabled: true
|
||||
config:
|
||||
activate:
|
||||
on-profile: prod
|
||||
@@ -29,10 +29,4 @@ spring:
|
||||
cache: false
|
||||
|
||||
portal:
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: true
|
||||
resource-caching:
|
||||
enabled: true
|
||||
test-auth-notice-enabled: false # prod 환경: UI 안내 미표시
|
||||
@@ -1,6 +1,6 @@
|
||||
spring:
|
||||
jta:
|
||||
enabled: false
|
||||
enabled: true
|
||||
web:
|
||||
resources:
|
||||
cache:
|
||||
|
||||
@@ -2,9 +2,7 @@ server:
|
||||
servlet:
|
||||
context-path: /
|
||||
session:
|
||||
timeout: 10m
|
||||
cookie:
|
||||
name: PORTAL_JSESSIONID
|
||||
timeout: 15m
|
||||
encoding:
|
||||
charset: UTF-8
|
||||
port: '39130'
|
||||
@@ -26,6 +24,8 @@ spring:
|
||||
default-page-size: '10'
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
|
||||
web:
|
||||
resources:
|
||||
@@ -41,11 +41,10 @@ spring:
|
||||
# 아래 app.resource-versioning.enabled 토글을 읽어 직접 수행한다.
|
||||
|
||||
jta:
|
||||
enabled: false
|
||||
enabled: true
|
||||
atomikos:
|
||||
properties:
|
||||
# log-base-dir: /logs/eapim/${inst.Name:devSvr11}/atomikos
|
||||
log-base-dir: /dev/null
|
||||
log-base-dir: /logs/atomikos
|
||||
log-base-name: adp_tx
|
||||
|
||||
thymeleaf:
|
||||
@@ -62,12 +61,7 @@ spring:
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
app:
|
||||
resource-versioning:
|
||||
enabled: false
|
||||
# 정적자원 서버측 캐싱(ResourceChain 캐시) 토글 (prod 제외 전 프로파일에 적용).
|
||||
# false 면 sass/JS 변경이 서버 재시작 없이 즉시 반영됨(매 요청 재해석).
|
||||
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
|
||||
resource-caching:
|
||||
enabled: false
|
||||
enabled: true
|
||||
|
||||
security:
|
||||
basic:
|
||||
@@ -258,9 +252,6 @@ page:
|
||||
verification_email:
|
||||
name: "이메일 인증"
|
||||
path: "/signup/verification-email"
|
||||
decision:
|
||||
name: "법인회원 초대"
|
||||
path: "/signup/decision_process"
|
||||
agreements:
|
||||
name: 약관
|
||||
path: "#"
|
||||
|
||||
@@ -5,19 +5,9 @@
|
||||
|
||||
<property name="LOG_PATH" value="${profileLogPath}/${inst.Name:-devSvr00}"/>
|
||||
|
||||
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
|
||||
|
||||
<!-- 콘솔 로그 JVM 파라미터 제어 -->
|
||||
<!-- 사용: -DCONSOLE_LOG_ENABLED=true -DCONSOLE_LOG_LEVEL=DEBUG -->
|
||||
<!-- 기본값: 비활성(콘솔 OFF), 활성 시 레벨 INFO -->
|
||||
<property name="CONSOLE_LOG_ENABLED" value="${CONSOLE_LOG_ENABLED:-false}"/>
|
||||
<property name="CONSOLE_LOG_LEVEL" value="${CONSOLE_LOG_LEVEL:-INFO}"/>
|
||||
<!-- enabled=true → 지정 레벨, false → OFF (대소문자 모두 대응) -->
|
||||
<property name="__consoleLevel.true" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.TRUE" value="${CONSOLE_LOG_LEVEL}"/>
|
||||
<property name="__consoleLevel.false" value="OFF"/>
|
||||
<property name="__consoleLevel.FALSE" value="OFF"/>
|
||||
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
|
||||
<!-- <property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger{5} - %msg %n" />-->
|
||||
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %magenta([%thread]) %highlight([%-3level]) %logger - %msg %n" />
|
||||
|
||||
|
||||
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
@@ -57,9 +47,6 @@
|
||||
</appender>
|
||||
|
||||
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
|
||||
<level>${CONSOLE_EFFECTIVE_LEVEL}</level>
|
||||
</filter>
|
||||
<encoder><Pattern>${CONSOLE_PATTERN}</Pattern></encoder>
|
||||
</appender>
|
||||
|
||||
@@ -71,7 +58,6 @@
|
||||
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
@@ -80,4 +66,15 @@
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
<springProfile name="stage">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
|
||||
<springProfile name="stage,dev">
|
||||
|
||||
</springProfile>
|
||||
</configuration>
|
||||
|
After Width: | Height: | Size: 380 KiB |
|
After Width: | Height: | Size: 380 KiB |
@@ -1,3 +1,4 @@
|
||||
<svg width="1920" height="314" viewBox="0 0 1920 314" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1920 314H0V0.447266C158.076 115.873 527.685 196.879 958.538 196.999H960.462C1391.87 196.878 1761.87 115.662 1919.61 0H1920V314Z" fill="#CFF2FF"/>
|
||||
<svg width="1920" height="318" viewBox="0 0 1920 318" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1920 318H0V64.4473C158.076 179.873 527.685 260.879 958.538 260.999H960.462C1391.87 260.878 1761.87 179.662 1919.61 64H1920V318Z" fill="#D3EFFB"/>
|
||||
<path d="M1920 281H0V13.71C158.194 148.461 528.235 243 959.499 243C1391.68 243 1762.37 148.059 1920 12.8525V0H1920V281ZM0 0V13.71H-0.000976562V0H0Z" fill="#CFF2FF" fill-opacity="0.5"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 269 B After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 537 KiB |
@@ -79,6 +79,9 @@
|
||||
li.innerHTML = ''
|
||||
+ '<div class="djb-comment-meta">'
|
||||
+ ' <div class="djb-comment-meta-left">'
|
||||
+ ' <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style="margin-right: 2px; opacity: 0.5;">'
|
||||
+ ' <path d="M5 3v5a2 2 0 0 0 2 2h5 M9 7l3 3-3 3" stroke="#000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
+ ' </svg>'
|
||||
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
|
||||
+ (c.adminYn === 'Y' ? ' <span class="djb-comment-admin-badge">관리자</span>' : '')
|
||||
+ ' </div>'
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
/*!
|
||||
* djb-swagger-i18n.js
|
||||
* Swagger UI 영문 라벨을 한글로 치환하는 i18n 패치 (방법 B - DOM patch)
|
||||
* 배포 경로 권장: static/plugins/swaggerUI/djb-swagger-i18n.js
|
||||
* index.html 의 swagger-initializer.js 직전 또는 직후에 <script> 로 로드
|
||||
*
|
||||
* ?lang=en 쿼리스트링이 있으면 패치 비활성화 (원문 보기)
|
||||
* 매핑 카탈로그는 01-i18n-mapping/i18n-strings.csv 와 동기 유지
|
||||
*/
|
||||
(function (window) {
|
||||
'use strict';
|
||||
|
||||
// ── 비활성화 옵션 ──
|
||||
if (/[?&]lang=en\b/.test(window.location.search)) return;
|
||||
|
||||
// ── 매핑 (key 는 디버깅용, 실제 비교는 EN 문자열 일치) ──
|
||||
var I18N_MAP = {
|
||||
// P0 — OAuth2 모달 본문
|
||||
'auth.modal.title': { en: 'Available authorizations', ko: '사용 가능한 인증' },
|
||||
'auth.scopes.description': {
|
||||
en: 'Scopes are used to grant an application different levels of access to data on behalf of the end user. Each API may declare one or more scopes.',
|
||||
ko: '스코프는 최종 사용자를 대신하여 애플리케이션이 데이터에 접근할 수 있는 권한 수준을 부여합니다. 각 API는 하나 이상의 스코프를 선언할 수 있습니다.'
|
||||
},
|
||||
'auth.scopes.swagger_grant': {
|
||||
en: 'API requires the following scopes. Select which ones you want to grant to Swagger UI.',
|
||||
ko: '이 API 는 아래 스코프가 필요합니다. Swagger UI 에 부여할 스코프를 선택하세요.'
|
||||
},
|
||||
'auth.flow.clientCredentials': { en: 'OAuth2 client credentials flow', ko: 'OAuth2 클라이언트 자격증명 흐름' },
|
||||
'auth.flow.implicit': { en: 'OAuth2 implicit flow', ko: 'OAuth2 암묵적 흐름' },
|
||||
'auth.flow.password': { en: 'OAuth2 password flow', ko: 'OAuth2 비밀번호 흐름' },
|
||||
'auth.flow.authorizationCode': { en: 'OAuth2 authorization code flow', ko: 'OAuth2 인가 코드 흐름' },
|
||||
'auth.field.tokenUrl': { en: 'Token URL:', ko: '토큰 URL:' },
|
||||
'auth.field.flow': { en: 'Flow:', ko: '흐름:' },
|
||||
'auth.field.clientId': { en: 'client_id:', ko: '클라이언트 ID (client_id):' },
|
||||
'auth.field.clientSecret': { en: 'client_secret:',ko: '클라이언트 시크릿 (client_secret):' },
|
||||
'auth.field.scopes': { en: 'Scopes:', ko: '스코프:' },
|
||||
'auth.action.selectAll': { en: 'select all', ko: '모두 선택' },
|
||||
'auth.action.selectNone': { en: 'select none', ko: '선택 해제' },
|
||||
'auth.action.authorize': { en: 'Authorize', ko: '인증' },
|
||||
'auth.action.close': { en: 'Close', ko: '닫기' },
|
||||
'auth.action.logout': { en: 'Logout', ko: '로그아웃' },
|
||||
'auth.modal.authorized': { en: 'authorized', ko: '인증됨' },
|
||||
|
||||
// P1 — 기타 인증 흐름
|
||||
'auth.scheme.apiKey': { en: 'API key', ko: 'API 키' },
|
||||
'auth.field.name': { en: 'name:', ko: '이름:' },
|
||||
'auth.field.in': { en: 'in:', ko: '위치:' },
|
||||
'auth.field.value': { en: 'Value:', ko: '값:' },
|
||||
'basic.field.username': { en: 'Username:',ko: '사용자 ID:' },
|
||||
'basic.field.password': { en: 'Password:',ko: '비밀번호:' },
|
||||
|
||||
// P2 — 오퍼레이션 / 파라미터 / 응답 UI 라벨 (PoC ko-i18n.js 카탈로그와 동기)
|
||||
'op.tryItOut': { en: 'Try it out', ko: '실행해보기' },
|
||||
'op.cancel': { en: 'Cancel', ko: '취소' },
|
||||
'op.execute': { en: 'Execute', ko: '실행' },
|
||||
'op.clear': { en: 'Clear', ko: '지우기' },
|
||||
'op.reset': { en: 'Reset', ko: '초기화' },
|
||||
'op.parameters': { en: 'Parameters', ko: '파라미터' },
|
||||
'op.parameter': { en: 'Parameter', ko: '파라미터' },
|
||||
'op.name': { en: 'Name', ko: '이름' },
|
||||
'op.description': { en: 'Description', ko: '설명' },
|
||||
'op.value': { en: 'Value', ko: '값' },
|
||||
'op.type': { en: 'Type', ko: '타입' },
|
||||
'op.requestBody': { en: 'Request body', ko: '요청 본문' },
|
||||
'op.requestUrl': { en: 'Request URL', ko: '요청 URL' },
|
||||
'op.responses': { en: 'Responses', ko: '응답' },
|
||||
'op.response': { en: 'Response', ko: '응답' },
|
||||
'op.responseBody': { en: 'Response body', ko: '응답 본문' },
|
||||
'op.responseHeaders': { en: 'Response headers', ko: '응답 헤더' },
|
||||
'op.responseCType': { en: 'Response content type', ko: '응답 컨텐츠 타입' },
|
||||
'op.code': { en: 'Code', ko: '코드' },
|
||||
'op.details': { en: 'Details', ko: '상세' },
|
||||
'op.schema': { en: 'Schema', ko: '스키마' },
|
||||
'op.schemas': { en: 'Schemas', ko: '스키마' },
|
||||
'op.exampleValue': { en: 'Example Value', ko: '예시 값' },
|
||||
'op.noParameters': { en: 'No parameters', ko: '파라미터 없음' },
|
||||
'op.required': { en: 'required', ko: '필수' },
|
||||
'op.servers': { en: 'Servers', ko: '서버' },
|
||||
'op.serverResponse': { en: 'Server response', ko: '서버 응답' },
|
||||
'op.loading': { en: 'Loading...', ko: '불러오는 중...' },
|
||||
'op.curlUpper': { en: 'Curl', ko: 'cURL' },
|
||||
'op.curlLower': { en: 'curl', ko: 'cURL' },
|
||||
'op.editValue': { en: 'Edit Value', ko: '값 편집' },
|
||||
'op.model': { en: 'Model', ko: '모델' },
|
||||
'op.snippets': { en: 'Snippets', ko: '스니펫' },
|
||||
'op.mediaType': { en: 'Media type', ko: '미디어 타입' },
|
||||
'op.controlsAccept': { en: 'Controls Accept header.', ko: 'Accept 헤더를 제어합니다.' },
|
||||
'op.sendEmptyValue': { en: 'Send empty value', ko: '빈 값 전송' },
|
||||
'op.hide': { en: 'Hide', ko: '숨기기' },
|
||||
'op.show': { en: 'Show', ko: '표시' }
|
||||
};
|
||||
|
||||
// 빠른 lookup 을 위해 EN → KO 인덱스 사전 생성
|
||||
var EN_TO_KO = Object.create(null);
|
||||
Object.keys(I18N_MAP).forEach(function (k) {
|
||||
var e = I18N_MAP[k];
|
||||
EN_TO_KO[e.en] = e.ko;
|
||||
});
|
||||
|
||||
// ── 치환 대상 노드 식별 + 치환 ──
|
||||
function translateNode(node) {
|
||||
if (!node) return;
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
var raw = node.nodeValue;
|
||||
if (!raw) return;
|
||||
var trimmed = raw.trim();
|
||||
if (!trimmed) return;
|
||||
// 1) 완전 일치 (가장 안전)
|
||||
if (EN_TO_KO[trimmed]) {
|
||||
node.nodeValue = raw.replace(trimmed, EN_TO_KO[trimmed]);
|
||||
return;
|
||||
}
|
||||
// 2) 라벨 colon 패턴: "Token URL:" 같이 끝이 ":" 인 짧은 라벨
|
||||
// (불필요 — 1번 완전 일치로 대부분 커버)
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') return;
|
||||
|
||||
// input placeholder / button title 도 일부 케이스에 대비
|
||||
if (node.tagName === 'INPUT' && node.placeholder && EN_TO_KO[node.placeholder.trim()]) {
|
||||
node.placeholder = EN_TO_KO[node.placeholder.trim()];
|
||||
}
|
||||
|
||||
var i, child = node.childNodes, len = child.length;
|
||||
for (i = 0; i < len; i++) translateNode(child[i]);
|
||||
}
|
||||
|
||||
function translateAll() {
|
||||
var root = document.getElementById('swagger-ui') || document.body;
|
||||
translateNode(root);
|
||||
}
|
||||
|
||||
// ── MutationObserver — SwaggerUI 가 동적으로 DOM 을 다시 그릴 때마다 재적용 ──
|
||||
var observer = new MutationObserver(function (mutations) {
|
||||
for (var i = 0; i < mutations.length; i++) {
|
||||
var m = mutations[i];
|
||||
if (m.addedNodes && m.addedNodes.length) {
|
||||
for (var j = 0; j < m.addedNodes.length; j++) translateNode(m.addedNodes[j]);
|
||||
}
|
||||
if (m.type === 'characterData') {
|
||||
translateNode(m.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function start() {
|
||||
translateAll();
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', start);
|
||||
} else {
|
||||
start();
|
||||
}
|
||||
})(window);
|
||||
@@ -1,350 +0,0 @@
|
||||
/*!
|
||||
* DjbSwaggerResponse — Swagger UI 기본 라이브 응답 표(.live-responses-table)를 CSS 로
|
||||
* 숨기고, 실행(Execute)한 operation 블록 안에 커스텀 응답 패널을 세로 적층으로 렌더한다.
|
||||
* (요청 스니펫 .request-snippets 은 같은 .responses-wrapper 안에 있으므로 유지)
|
||||
*
|
||||
* ┌─────────────────────────────────────────────┐
|
||||
* │ 응답 헤더 (Table) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ 응답 본문 (JSON Pretty / Raw) │
|
||||
* ├─────────────────────────────────────────────┤
|
||||
* │ 응답코드 칩 + Response Time(ms) │
|
||||
* └─────────────────────────────────────────────┘
|
||||
*
|
||||
* 포탈 testbed 는 API 하나에 operation 이 여러 개일 수 있으므로, 전역 단일 그리드가
|
||||
* 아니라 Execute 를 누른 opblock 안에 그리드를 만든다(클릭 캡처로 대상 특정).
|
||||
*
|
||||
* 연결:
|
||||
* const ui = SwaggerUIBundle({
|
||||
* responseInterceptor: function (res) { DjbSwaggerResponse.renderResponse(res); return res; }
|
||||
* });
|
||||
* window.ui = ui;
|
||||
* DjbSwaggerResponse.attach('#swagger-ui'); // Execute 클릭 캡처 + 타이머 등록
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var startTs = 0;
|
||||
var targetOpblock = null;
|
||||
|
||||
function now() {
|
||||
return (global.performance && performance.now) ? performance.now() : Date.now();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function statusKind(status) {
|
||||
if (status >= 200 && status < 300) return "ok";
|
||||
if (status >= 300 && status < 400) return "warn";
|
||||
if (status >= 400) return "err";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function prettyJson(text) {
|
||||
try { return JSON.stringify(JSON.parse(text), null, 2); }
|
||||
catch (e) { return null; }
|
||||
}
|
||||
|
||||
// window.ui 에서 해석된 spec(json) 조회. (snippet-panel 과 동일 방식)
|
||||
function getSpecJson() {
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (ui && typeof ui.spec === "function") {
|
||||
var s = ui.spec();
|
||||
if (s && typeof s.toJS === "function") {
|
||||
var o = s.toJS();
|
||||
return o.json || o;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// 단일 API 페이지 전제(첫 path/method)로 operation 반환.
|
||||
function getSingleOperation(spec) {
|
||||
var paths = (spec && spec.paths) ? spec.paths : {};
|
||||
var pk = Object.keys(paths)[0];
|
||||
if (!pk) return null;
|
||||
var ops = paths[pk];
|
||||
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
|
||||
if (!mk) return null;
|
||||
return { path: pk, method: mk, operation: ops[mk] };
|
||||
}
|
||||
|
||||
// opblock 안 파라미터 입력값(header/query) 읽기.
|
||||
function readParamInput(opblock, name, where) {
|
||||
var scope = opblock || document;
|
||||
var row = scope.querySelector('tr[data-param-name="' + name + '"][data-param-in="' + where + '"]');
|
||||
if (row) {
|
||||
var input = row.querySelector('input[type="text"], input:not([type]), textarea');
|
||||
if (input && input.value) return input.value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Execute 직전 상태에서 검증용 요청 객체를 조립(헤더/쿼리/본문).
|
||||
function buildValidationRequest(opblock, opInfo) {
|
||||
var op = opInfo.operation;
|
||||
var headers = {};
|
||||
var query = [];
|
||||
(op.parameters || []).forEach(function (p) {
|
||||
if (p.in === "header") {
|
||||
var hv = readParamInput(opblock, p.name, "header");
|
||||
if (hv !== undefined) headers[p.name] = hv;
|
||||
} else if (p.in === "query") {
|
||||
var qv = readParamInput(opblock, p.name, "query");
|
||||
if (qv !== undefined) query.push(encodeURIComponent(p.name) + "=" + encodeURIComponent(qv));
|
||||
}
|
||||
});
|
||||
var body = "";
|
||||
if (op.requestBody) {
|
||||
headers["Content-Type"] = headers["Content-Type"] || "application/json";
|
||||
var ta = (opblock || document).querySelector("textarea.body-param__text");
|
||||
if (ta) body = ta.value;
|
||||
}
|
||||
var url = "http://_local" + opInfo.path + (query.length ? "?" + query.join("&") : "");
|
||||
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
|
||||
}
|
||||
|
||||
function renderEmpty() {
|
||||
return ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">요청 중…</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
|
||||
+ '<div class="empty">요청 중…</div></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip neutral">대기</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function renderHeadersTable(headers) {
|
||||
var keys = Object.keys(headers || {});
|
||||
if (!keys.length) return '<div class="empty">헤더가 없습니다.</div>';
|
||||
var rows = keys.map(function (k) {
|
||||
return '<tr><td class="hdr-name">' + escapeHtml(k) + '</td>'
|
||||
+ '<td class="hdr-val">' + escapeHtml(headers[k]) + '</td></tr>';
|
||||
}).join("");
|
||||
return '<table class="hdr-table"><thead><tr><th>이름</th><th>값</th></tr></thead>'
|
||||
+ '<tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function bodyTabs(rawText) {
|
||||
var pretty = prettyJson(rawText);
|
||||
var hasJson = pretty != null;
|
||||
var jsonTab = hasJson ? '<button type="button" class="body-tab active" data-tab="json">JSON (Pretty)</button>' : "";
|
||||
var rawTab = '<button type="button" class="body-tab' + (hasJson ? "" : " active") + '" data-tab="raw">Raw</button>';
|
||||
var jsonPanel = hasJson ? '<pre class="body-panel mono active" data-tab="json">' + escapeHtml(pretty) + '</pre>' : "";
|
||||
var rawPanel = '<pre class="body-panel mono' + (hasJson ? "" : " active") + '" data-tab="raw">' + escapeHtml(rawText || "") + '</pre>';
|
||||
return '<div class="body-tabs" role="tablist">' + jsonTab + rawTab + '</div>'
|
||||
+ '<div class="body-panels">' + jsonPanel + rawPanel + '</div>';
|
||||
}
|
||||
|
||||
function attachTabHandlers(root) {
|
||||
if (root.__djbTabBound) return;
|
||||
root.__djbTabBound = true;
|
||||
root.addEventListener("click", function (e) {
|
||||
var btn = e.target.closest && e.target.closest(".body-tab");
|
||||
if (!btn) return;
|
||||
var panels = root.querySelectorAll(".body-panel");
|
||||
var tabs = root.querySelectorAll(".body-tab");
|
||||
tabs.forEach(function (t) { t.classList.toggle("active", t === btn); });
|
||||
var which = btn.getAttribute("data-tab");
|
||||
panels.forEach(function (p) { p.classList.toggle("active", p.getAttribute("data-tab") === which); });
|
||||
});
|
||||
}
|
||||
|
||||
// 대상 opblock(없으면 열려있는/첫 opblock) 안 .opblock-body 에 그리드 1개 확보.
|
||||
// 항상 opblock-body 최하단으로 이동시킨다: Execute 시점엔 responses-wrapper(요청
|
||||
// 스니펫)가 아직 없어 그리드가 그 위에 붙지만, 응답 후 재호출 시 최하단으로 옮겨
|
||||
// "Execute → 요청 스니펫 → 응답 그리드" 순서를 보장한다.
|
||||
function gridFor(opblock) {
|
||||
var host = opblock
|
||||
|| document.querySelector("#swagger-ui .opblock.is-open")
|
||||
|| document.querySelector("#swagger-ui .opblock");
|
||||
if (!host) return null;
|
||||
var body = host.querySelector(".opblock-body");
|
||||
if (!body) return null; // 접힘 상태에는 마운트하지 않음 (bare opblock 오배치 방지)
|
||||
var grid = body.querySelector(".djb-response-grid");
|
||||
if (!grid) {
|
||||
grid = document.createElement("div");
|
||||
grid.className = "djb-response-grid djb-empty";
|
||||
grid.innerHTML = renderEmpty();
|
||||
attachTabHandlers(grid);
|
||||
}
|
||||
body.appendChild(grid); // 기존 노드면 최하단으로 이동
|
||||
return grid;
|
||||
}
|
||||
|
||||
// 검증 실패 렌더: 네트워크 요청이 발생하지 않은 상태. djb-error 스타일 그리드.
|
||||
function renderValidationErrors(errors) {
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (!grid) return;
|
||||
var items = (errors || []).map(function (er) {
|
||||
return '<li class="verr-item">'
|
||||
+ '<span class="verr-path">' + escapeHtml(er.path) + '</span> '
|
||||
+ '<span class="verr-msg">' + escapeHtml(er.message) + '</span>'
|
||||
+ (er.value !== undefined ? ' <code class="verr-val mono">' + escapeHtml(JSON.stringify(er.value)) + '</code>' : "")
|
||||
+ '</li>';
|
||||
}).join("");
|
||||
grid.classList.remove("djb-empty");
|
||||
grid.classList.add("djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">검증 실패 — 요청이 전송되지 않았습니다.</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">검증 실패 (' + (errors || []).length + '건)</div>'
|
||||
+ '<ul class="verr-list">' + items + '</ul></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip err">검증실패</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// 네트워크/CORS 실패 렌더: responseInterceptor 로 오지 않고 store 에만 error 로 남는 케이스.
|
||||
function renderNetworkError(msg) {
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (!grid) return;
|
||||
grid.classList.remove("djb-empty");
|
||||
grid.classList.add("djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ '<div class="empty">응답을 받지 못했습니다.</div></div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">네트워크 오류</div>'
|
||||
+ '<pre class="body-panel mono active" data-tab="raw">요청 실패: ' + escapeHtml(msg) + '</pre></div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip err">네트워크 오류</span>'
|
||||
+ '<span class="time">Response Time: -</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
// Execute 클릭 시점에 대상 opblock 확정 + (검증) + 타이머 시작 + 빈 그리드 표시.
|
||||
// 캡처 단계라 Swagger(React) 의 실행 핸들러보다 먼저 실행됨 → 검증 실패 시
|
||||
// stopImmediatePropagation 으로 native 실행을 차단하고 에러만 렌더한다.
|
||||
function onExecuteClick(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.closest) return;
|
||||
var btn = t.closest(".btn.execute");
|
||||
if (!btn) return;
|
||||
targetOpblock = btn.closest(".opblock");
|
||||
|
||||
// ── 요청 파라미터/본문 검증 (검증기 로드된 경우) ──
|
||||
if (global.DjbSwaggerValidator) {
|
||||
try {
|
||||
var spec = getSpecJson();
|
||||
var opInfo = spec && getSingleOperation(spec);
|
||||
if (opInfo) {
|
||||
var req = buildValidationRequest(targetOpblock, opInfo);
|
||||
var result = global.DjbSwaggerValidator.validateOperation(opInfo.operation, req, spec);
|
||||
if (!result.ok) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
renderValidationErrors(result.errors);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) { console.error("djb validation error:", err); }
|
||||
}
|
||||
|
||||
startTs = now();
|
||||
var grid = gridFor(targetOpblock);
|
||||
if (grid) {
|
||||
grid.classList.add("djb-empty");
|
||||
grid.classList.remove("djb-error");
|
||||
grid.innerHTML = renderEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} res Swagger UI responseInterceptor 결과
|
||||
* { status, statusText, headers, text, data, ... }
|
||||
*/
|
||||
function renderResponse(res) {
|
||||
if (!res) return;
|
||||
// spec 로딩 응답(/…/swagger.json)은 responseInterceptor 로도 들어온다. 이때 렌더하면
|
||||
// 실행 전인데 응답 본문에 OpenAPI spec 이 그려지므로 무시한다. 사용자 API 호출은
|
||||
// 프록시(/api/call-api)로 나가므로 url 에 swagger.json 이 없다.
|
||||
if (res.url && res.url.indexOf("swagger.json") !== -1) return;
|
||||
var ms = Math.max(0, Math.round(now() - startTs));
|
||||
var status = res.status || 0;
|
||||
var statusText = res.statusText || "";
|
||||
var headers = res.headers || {};
|
||||
var rawText = (typeof res.text === "string") ? res.text
|
||||
: (typeof res.data === "string") ? res.data
|
||||
: (res.data != null ? JSON.stringify(res.data) : "");
|
||||
var op = targetOpblock;
|
||||
|
||||
// Swagger(React) 가 응답 렌더로 opblock-body 를 재조정한 뒤 append 되도록 지연.
|
||||
setTimeout(function () {
|
||||
var grid = gridFor(op);
|
||||
if (!grid) return;
|
||||
var kind = statusKind(status);
|
||||
grid.classList.remove("djb-empty", "djb-error");
|
||||
grid.innerHTML = ''
|
||||
+ '<div class="cell-headers"><div class="cell-title">응답 헤더</div>'
|
||||
+ renderHeadersTable(headers) + '</div>'
|
||||
+ '<div class="cell-body"><div class="cell-title">응답 본문</div>'
|
||||
+ bodyTabs(rawText) + '</div>'
|
||||
+ '<div class="cell-meta">'
|
||||
+ '<span class="status-chip ' + kind + '">응답코드: ' + escapeHtml(status)
|
||||
+ (statusText ? " " + escapeHtml(statusText) : "") + '</span>'
|
||||
+ '<span class="time">Response Time: ' + ms + ' ms</span>'
|
||||
+ '</div>';
|
||||
attachTabHandlers(grid);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// fetch 실패(네트워크/CORS/타임아웃)는 responseInterceptor 로 전달되지 않고 store 의
|
||||
// spec.responses[path][method] 에 { error:true, err } 로만 저장된다. store 를 구독해
|
||||
// 사용자에게 네트워크 오류 그리드로 보여준다. (PoC bootstrap 의 getStore().subscribe 이식)
|
||||
function watchNetworkErrors(ui) {
|
||||
if (!ui || typeof ui.getStore !== "function" || ui.__djbNetWatch) return;
|
||||
ui.__djbNetWatch = true;
|
||||
var rendered = {};
|
||||
ui.getStore().subscribe(function () {
|
||||
try {
|
||||
var respMap = ui.getState().getIn(["spec", "responses"]);
|
||||
if (!respMap || typeof respMap.forEach !== "function") return;
|
||||
respMap.forEach(function (methods, path) {
|
||||
if (!methods || typeof methods.forEach !== "function") return;
|
||||
methods.forEach(function (resp, method) {
|
||||
var key = path + " " + method;
|
||||
var hasError = resp && (resp.get ? resp.get("error") : resp.error);
|
||||
if (hasError) {
|
||||
if (rendered[key]) return;
|
||||
rendered[key] = true;
|
||||
var err = resp.get ? resp.get("err") : resp.err;
|
||||
var msg = (err && (err.message || (err.get && err.get("message"))))
|
||||
|| (err ? String(err) : "응답을 받지 못했습니다.");
|
||||
renderNetworkError(String(msg));
|
||||
} else {
|
||||
rendered[key] = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) { /* ignore */ }
|
||||
});
|
||||
}
|
||||
|
||||
// #swagger-ui 는 API 전환 시에도 element 자체는 유지되므로 캡처 리스너 1회 등록으로 충분.
|
||||
function attach(rootSel) {
|
||||
var root = document.querySelector(rootSel || "#swagger-ui");
|
||||
if (!root || root.__djbRespBound) return;
|
||||
root.__djbRespBound = true;
|
||||
// capture 단계: native 응답 흐름보다 먼저 타겟/타이머 확보 + 검증 차단
|
||||
root.addEventListener("click", onExecuteClick, true);
|
||||
// window.ui 준비 시 네트워크 오류 감시 등록
|
||||
watchNetworkErrors(global.ui);
|
||||
}
|
||||
|
||||
global.DjbSwaggerResponse = {
|
||||
attach: attach,
|
||||
renderResponse: renderResponse,
|
||||
renderValidationErrors: renderValidationErrors,
|
||||
renderNetworkError: renderNetworkError,
|
||||
watchNetworkErrors: watchNetworkErrors
|
||||
};
|
||||
})(window);
|
||||
@@ -1,265 +0,0 @@
|
||||
/*!
|
||||
* DjbSwaggerSnippetPanel — Execute 없이 페이지 진입 즉시 요청 스니펫을 보여주는 상시 패널.
|
||||
* (PoC rinjae-works/swagger-poc/js/snippet-panel.js 를 포탈용으로 이식)
|
||||
*
|
||||
* Swagger UI 5 의 내장 .request-snippets 는 Execute 이후에만, 그리고 응답영역 안에
|
||||
* 렌더되므로 CSS 로 숨기고, 동일한 window.SnippetGenerators 로 직접 패널을 만들어
|
||||
* operation 의 Execute 버튼(.btn-group) 바로 아래에 마운트한다. Body/헤더 변경 시 자동 갱신.
|
||||
*
|
||||
* 전제: 이 페이지는 항상 1개 API(단일 operation) 노출 — spec 의 첫 path/method 사용.
|
||||
* spec 은 window.ui 에서 지연 조회한다.
|
||||
*
|
||||
* 연결: SwaggerUIBundle 의 onComplete 에서 DjbSwaggerSnippetPanel.mount() 호출.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var TABS = [
|
||||
{ key: "curl_bash", title: "cURL (Bash)", gen: "curlBash" },
|
||||
{ key: "curl_powershell", title: "cURL (PowerShell)", gen: "curlPwsh" },
|
||||
{ key: "curl_cmd", title: "cURL (CMD)", gen: "curlCmd" },
|
||||
{ key: "csharp", title: "C#", gen: "csharp" },
|
||||
{ key: "ecma5", title: "ECMA5 (JavaScript)", gen: "ecma5" },
|
||||
{ key: "java", title: "Java", gen: "javaOkhttp" },
|
||||
{ key: "python", title: "Python", gen: "python" }
|
||||
];
|
||||
|
||||
var activeKey = "curl_bash";
|
||||
var rootSel = "#swagger-ui";
|
||||
var observer = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&").replace(/</g, "<")
|
||||
.replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
// window.ui 에서 해석된 spec(json) 조회.
|
||||
function getSpecJson() {
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (ui && typeof ui.spec === "function") {
|
||||
var s = ui.spec();
|
||||
if (s && typeof s.toJS === "function") {
|
||||
var o = s.toJS();
|
||||
return o.json || o;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* noop */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
// Path 1 + Method 1 전제 하에 단일 operation 반환.
|
||||
function getSingleOperation(spec) {
|
||||
var paths = (spec && spec.paths) ? spec.paths : {};
|
||||
var pk = Object.keys(paths)[0];
|
||||
if (!pk) return null;
|
||||
var ops = paths[pk];
|
||||
var mk = ["get", "post", "put", "patch", "delete", "options", "head"].filter(function (m) { return ops[m]; })[0];
|
||||
if (!mk) return null;
|
||||
return { path: pk, method: mk, operation: ops[mk] };
|
||||
}
|
||||
|
||||
function readHeaderInput(name) {
|
||||
var row = document.querySelector('tr[data-param-name="' + name + '"][data-param-in="header"]');
|
||||
if (row) {
|
||||
var input = row.querySelector('input[type="text"], input:not([type])');
|
||||
if (input && input.value) return input.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Authorize 모달 사용 후에만 표시되는 인증 헤더 추출.
|
||||
function readAuthHeaders() {
|
||||
var headers = {};
|
||||
try {
|
||||
var ui = global.ui;
|
||||
if (!ui || !ui.authSelectors) return headers;
|
||||
var auths = ui.authSelectors.authorized();
|
||||
if (!auths) return headers;
|
||||
var js = auths.toJS();
|
||||
Object.keys(js).forEach(function (key) {
|
||||
var a = js[key];
|
||||
if (!a || !a.schema) return;
|
||||
var scheme = a.schema;
|
||||
if (scheme.type === "oauth2") {
|
||||
var tok = a.token && a.token.access_token;
|
||||
if (tok) headers["Authorization"] = "Bearer " + tok;
|
||||
} else if (scheme.type === "apiKey" && scheme.in === "header") {
|
||||
if (a.value) headers[scheme.name] = a.value;
|
||||
} else if (scheme.type === "http" && scheme.scheme === "bearer") {
|
||||
if (a.value) headers["Authorization"] = "Bearer " + a.value;
|
||||
}
|
||||
});
|
||||
} catch (e) { /* noop */ }
|
||||
return headers;
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
var spec = getSpecJson();
|
||||
var opInfo = spec && getSingleOperation(spec);
|
||||
if (!opInfo) return null;
|
||||
var op = opInfo.operation;
|
||||
var server = (spec.servers && spec.servers[0] && spec.servers[0].url) || "";
|
||||
var url = server + opInfo.path;
|
||||
|
||||
var headers = {};
|
||||
(op.parameters || []).forEach(function (p) {
|
||||
if (p.in !== "header") return;
|
||||
var val = readHeaderInput(p.name)
|
||||
|| (p.example !== undefined ? String(p.example) : null)
|
||||
|| (p.schema && p.schema.example !== undefined ? String(p.schema.example) : null);
|
||||
if (val) headers[p.name] = val;
|
||||
});
|
||||
|
||||
var body = "";
|
||||
if (op.requestBody && op.requestBody.content) {
|
||||
var cts = Object.keys(op.requestBody.content);
|
||||
var ct = cts[0] || "application/json";
|
||||
headers["Content-Type"] = ct;
|
||||
var ta = document.querySelector(rootSel + " textarea.body-param__text");
|
||||
if (ta && ta.value) {
|
||||
body = ta.value;
|
||||
} else if (op.requestBody.content[ct] && op.requestBody.content[ct].example !== undefined) {
|
||||
try { body = JSON.stringify(op.requestBody.content[ct].example, null, 2); } catch (e) { body = ""; }
|
||||
}
|
||||
}
|
||||
headers["accept"] = headers["accept"] || "application/json";
|
||||
var auth = readAuthHeaders();
|
||||
Object.keys(auth).forEach(function (k) { headers[k] = auth[k]; });
|
||||
|
||||
return { url: url, method: opInfo.method.toUpperCase(), headers: headers, body: body };
|
||||
}
|
||||
|
||||
function panelEl() { return document.querySelector("#djb-snippet-panel"); }
|
||||
|
||||
function renderInto(el) {
|
||||
if (!el) return;
|
||||
var gens = global.SnippetGenerators;
|
||||
var code;
|
||||
if (!gens) {
|
||||
code = "// 스니펫 생성기 로드 대기…";
|
||||
} else {
|
||||
try {
|
||||
var req = buildRequest();
|
||||
if (!req) { code = "// 스펙 로딩 중…"; }
|
||||
else {
|
||||
var active = TABS.filter(function (t) { return t.key === activeKey; })[0] || TABS[0];
|
||||
code = gens[active.gen](req);
|
||||
}
|
||||
} catch (e) { code = "// 스니펫 생성 오류: " + e.message; }
|
||||
}
|
||||
var tabsHtml = TABS.map(function (t) {
|
||||
return '<button type="button" class="djb-sn-tab ' + (t.key === activeKey ? "active" : "") + '" data-key="' + t.key + '">' + t.title + '</button>';
|
||||
}).join("");
|
||||
el.innerHTML =
|
||||
'<div class="djb-sn-header">'
|
||||
+ '<h4>요청 스니펫</h4>'
|
||||
+ '<span class="djb-sn-hint">(실행 없이 즉시 미리보기 — Body / 헤더 변경 시 자동 갱신)</span>'
|
||||
+ '<button type="button" class="djb-sn-copy" data-action="copy">복사</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="djb-sn-tabs" role="tablist">' + tabsHtml + '</div>'
|
||||
+ '<pre class="djb-sn-code mono">' + escapeHtml(code) + '</pre>';
|
||||
}
|
||||
|
||||
// 옵저버 재진입(무한 루프) 방지: 우리 DOM 변경 중에는 관찰 일시 중지.
|
||||
function withObserverPaused(fn) {
|
||||
if (observer) observer.disconnect();
|
||||
try { fn(); }
|
||||
finally {
|
||||
if (observer) {
|
||||
var root = document.querySelector(rootSel);
|
||||
if (root) observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute 영역 바로 아래 = Swagger UI 폼 하단에 패널 확보.
|
||||
// - 앵커는 opblock-body '직속' 자식만 본다: 실행 전에는 .execute-wrapper, 실행 후에는
|
||||
// Swagger UI 5 가 이를 .btn-group(실행+초기화 버튼군)으로 '교체'하므로 둘 다 허용한다.
|
||||
// (execute-wrapper 만 앵커로 쓰면 실행 후 앵커 소실 → 패널이 제거되고 재생성 안 됨.)
|
||||
// - 중첩 .btn-group(파라미터 섹션 헤더의 try-out '취소' 버튼군)은 직속 자식이 아니라
|
||||
// 건너뛴다: 그걸 앵커로 잡으면 flex 헤더 안에 삽입돼 "파라미터" 라벨이 찌부된다.
|
||||
// - operation 이 접혀 opblock-body/Execute 영역이 없으면: 잘못 남은 패널을 제거하고 중단.
|
||||
// 다음 펼침 때 재생성.
|
||||
// - 이미 있으나 위치가 어긋나면: anchor 바로 뒤로 재배치.
|
||||
function ensureMounted() {
|
||||
var op = document.querySelector(rootSel + " .opblock.is-open")
|
||||
|| document.querySelector(rootSel + " .opblock");
|
||||
var body = op && op.querySelector(".opblock-body");
|
||||
var anchor = null;
|
||||
if (body) {
|
||||
for (var i = 0; i < body.children.length; i++) {
|
||||
var kid = body.children[i];
|
||||
if (kid.classList.contains("execute-wrapper") || kid.classList.contains("btn-group")) {
|
||||
anchor = kid;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
var existing = panelEl();
|
||||
|
||||
if (!body || !anchor) {
|
||||
if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
if (existing.parentNode !== anchor.parentNode || existing.previousElementSibling !== anchor) {
|
||||
anchor.parentNode.insertBefore(existing, anchor.nextSibling);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var panel = document.createElement("div");
|
||||
panel.id = "djb-snippet-panel";
|
||||
panel.className = "djb-snippet-panel";
|
||||
anchor.parentNode.insertBefore(panel, anchor.nextSibling);
|
||||
renderInto(panel);
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
var tab = e.target.closest && e.target.closest("#djb-snippet-panel .djb-sn-tab");
|
||||
if (tab) {
|
||||
activeKey = tab.getAttribute("data-key");
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
return;
|
||||
}
|
||||
var copy = e.target.closest && e.target.closest('#djb-snippet-panel [data-action="copy"]');
|
||||
if (copy) {
|
||||
var code = document.querySelector("#djb-snippet-panel .djb-sn-code");
|
||||
if (code && global.navigator && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(code.innerText).then(function () {
|
||||
copy.textContent = "복사됨";
|
||||
setTimeout(function () { copy.textContent = "복사"; }, 1200);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
var t = e.target;
|
||||
if (!t || !t.matches) return;
|
||||
if (t.matches("textarea.body-param__text") || t.matches("tr[data-param-name] input")) {
|
||||
withObserverPaused(function () { renderInto(panelEl()); });
|
||||
}
|
||||
}
|
||||
|
||||
function mount(opts) {
|
||||
rootSel = (opts && opts.rootSel) || "#swagger-ui";
|
||||
var root = document.querySelector(rootSel);
|
||||
if (!root) return;
|
||||
if (!root.__djbSnBound) {
|
||||
root.__djbSnBound = true;
|
||||
root.addEventListener("click", onClick);
|
||||
root.addEventListener("input", onInput);
|
||||
observer = new MutationObserver(function () {
|
||||
withObserverPaused(function () { ensureMounted(); });
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
}
|
||||
withObserverPaused(function () { ensureMounted(); });
|
||||
}
|
||||
|
||||
global.DjbSwaggerSnippetPanel = { mount: mount, render: function () { renderInto(panelEl()); } };
|
||||
})(window);
|
||||
@@ -1,223 +0,0 @@
|
||||
/*!
|
||||
* DjbSwaggerSnippets — Swagger UI 의 SnippetGeneratorPlugin 에 등록되는 7개 언어
|
||||
* 클라이언트 코드 생성기. 서버측 /api/sample_code/{language} 호출을 대체한다.
|
||||
*
|
||||
* cURL (Bash) / cURL (PowerShell) / cURL (CMD) / C# / ECMA5 (JS) / Java / Python
|
||||
*
|
||||
* 입력: Swagger UI requestSnippetGenerator 가 넘기는 Immutable request.
|
||||
* .toJS() 로 plain object 변환 후 url/method/headers/body 사용.
|
||||
* (showMutatedRequest:false 이므로 프록시 URL 이 아닌 실제 대상 요청이 넘어온다)
|
||||
* 출력: string (코드 본문)
|
||||
*
|
||||
* 전역: window.SnippetGeneratorPlugin (Swagger plugins 배열에 등록),
|
||||
* window.SnippetGenerators (개별 생성 함수 접근용)
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function unwrap(req) {
|
||||
if (!req) return {};
|
||||
if (typeof req.toJS === "function") return req.toJS();
|
||||
return req;
|
||||
}
|
||||
|
||||
function getCt(headers) {
|
||||
for (const k of Object.keys(headers || {})) {
|
||||
if (k.toLowerCase() === "content-type") return headers[k];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bodyAsString(body) {
|
||||
if (body === undefined || body === null) return "";
|
||||
if (typeof body === "string") return body;
|
||||
try { return JSON.stringify(body); } catch (e) { return String(body); }
|
||||
}
|
||||
|
||||
function escapeSingleQuote(s) { return String(s).replace(/'/g, "'\\''"); }
|
||||
function escapeDoubleQuote(s) { return String(s).replace(/"/g, '\\"'); }
|
||||
|
||||
// ─── cURL (Bash) ────────────────────────────────────────────────────────────
|
||||
function curlBash(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const lines = [`curl --location --request ${r.method || "GET"} '${escapeSingleQuote(r.url)}'`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(` --header '${escapeSingleQuote(k)}: ${escapeSingleQuote(headers[k])}'`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
lines.push(` --data-raw '${escapeSingleQuote(body)}'`);
|
||||
}
|
||||
return lines.join(" \\\n");
|
||||
}
|
||||
|
||||
// ─── cURL (PowerShell) ─────────────────────────────────────────────────────
|
||||
function curlPwsh(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
// 백틱(`) 줄 연속자
|
||||
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
|
||||
}
|
||||
return segments.join(" `\n");
|
||||
}
|
||||
|
||||
// ─── cURL (Windows CMD) ────────────────────────────────────────────────────
|
||||
function curlCmd(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const segments = [`curl.exe --location --request ${r.method || "GET"} "${escapeDoubleQuote(r.url)}"`];
|
||||
const headers = r.headers || {};
|
||||
for (const k of Object.keys(headers)) {
|
||||
segments.push(` --header "${escapeDoubleQuote(k)}: ${escapeDoubleQuote(headers[k])}"`);
|
||||
}
|
||||
const body = bodyAsString(r.body);
|
||||
if (body) {
|
||||
segments.push(` --data-raw "${escapeDoubleQuote(body)}"`);
|
||||
}
|
||||
return segments.join(" ^\n");
|
||||
}
|
||||
|
||||
// ─── C# (RestSharp) ─────────────────────────────────────────────────────────
|
||||
function csharp(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const lines = [
|
||||
`using RestSharp;`,
|
||||
``,
|
||||
`var client = new RestClient("${escapeDoubleQuote(r.url)}");`,
|
||||
`var request = new RestRequest(Method.${method});`,
|
||||
];
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(`request.AddHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
|
||||
}
|
||||
if (body) {
|
||||
const ct = getCt(headers) || "application/json";
|
||||
lines.push(`request.AddParameter("${escapeDoubleQuote(ct)}", @"${body.replace(/"/g, '""')}", ParameterType.RequestBody);`);
|
||||
}
|
||||
lines.push(`IRestResponse response = client.Execute(request);`);
|
||||
lines.push(`Console.WriteLine(response.Content);`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── ECMA5 / JavaScript (XHR) ──────────────────────────────────────────────
|
||||
function ecma5(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const lines = [
|
||||
`var xhr = new XMLHttpRequest();`,
|
||||
`xhr.withCredentials = false;`,
|
||||
``,
|
||||
`xhr.addEventListener("readystatechange", function () {`,
|
||||
` if (this.readyState === 4) {`,
|
||||
` console.log(this.responseText);`,
|
||||
` }`,
|
||||
`});`,
|
||||
``,
|
||||
`xhr.open("${method}", "${escapeDoubleQuote(r.url)}");`,
|
||||
];
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(`xhr.setRequestHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}");`);
|
||||
}
|
||||
if (body) {
|
||||
// ES5 라 template literal 사용 X — JSON.stringify 로 안전한 문자열 리터럴 생성
|
||||
lines.push(`xhr.send(${JSON.stringify(body)});`);
|
||||
} else {
|
||||
lines.push(`xhr.send(null);`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Java (OkHttp) ─────────────────────────────────────────────────────────
|
||||
function javaOkhttp(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toUpperCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
const ct = getCt(headers) || "application/json";
|
||||
const lines = [
|
||||
`OkHttpClient client = new OkHttpClient().newBuilder().build();`,
|
||||
`MediaType mediaType = MediaType.parse("${escapeDoubleQuote(ct)}");`,
|
||||
];
|
||||
if (body) {
|
||||
lines.push(`RequestBody body = RequestBody.create(mediaType, "${escapeDoubleQuote(body)}");`);
|
||||
}
|
||||
lines.push(`Request request = new Request.Builder()`);
|
||||
lines.push(` .url("${escapeDoubleQuote(r.url)}")`);
|
||||
if (body) {
|
||||
lines.push(` .method("${method}", body)`);
|
||||
} else {
|
||||
lines.push(` .method("${method}", null)`);
|
||||
}
|
||||
for (const k of Object.keys(headers)) {
|
||||
lines.push(` .addHeader("${escapeDoubleQuote(k)}", "${escapeDoubleQuote(headers[k])}")`);
|
||||
}
|
||||
lines.push(` .build();`);
|
||||
lines.push(`Response response = client.newCall(request).execute();`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Python (requests) ─────────────────────────────────────────────────────
|
||||
function python(reqRaw) {
|
||||
const r = unwrap(reqRaw);
|
||||
const method = (r.method || "GET").toLowerCase();
|
||||
const headers = r.headers || {};
|
||||
const body = bodyAsString(r.body);
|
||||
|
||||
const headerEntries = Object.keys(headers).map(
|
||||
k => ` "${escapeDoubleQuote(k)}": "${escapeDoubleQuote(headers[k])}"`
|
||||
);
|
||||
const lines = [
|
||||
`import requests`,
|
||||
``,
|
||||
`url = "${escapeDoubleQuote(r.url)}"`,
|
||||
``,
|
||||
];
|
||||
if (body) {
|
||||
lines.push(`payload = ${JSON.stringify(body)}`);
|
||||
}
|
||||
lines.push(`headers = {`);
|
||||
if (headerEntries.length) lines.push(headerEntries.join(",\n"));
|
||||
lines.push(`}`);
|
||||
lines.push(``);
|
||||
if (body) {
|
||||
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers, data=payload)`);
|
||||
} else {
|
||||
lines.push(`response = requests.request("${method.toUpperCase()}", url, headers=headers)`);
|
||||
}
|
||||
lines.push(`print(response.text)`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ─── Swagger UI 플러그인 등록 페이로드 ──────────────────────────────────────
|
||||
const SnippetGeneratorPlugin = function () {
|
||||
return {
|
||||
fn: {
|
||||
// curl_bash / curl_powershell / curl_cmd 는 Swagger UI 5 내장 기본 키와 동일하게
|
||||
// 맞춰 내장 생성기를 덮어쓴다. (curl_pwsh 등 다른 키를 쓰면 내장 PowerShell 탭이
|
||||
// 중복 표시됨)
|
||||
requestSnippetGenerator_curl_bash: curlBash,
|
||||
requestSnippetGenerator_curl_powershell: curlPwsh,
|
||||
requestSnippetGenerator_curl_cmd: curlCmd,
|
||||
requestSnippetGenerator_csharp: csharp,
|
||||
requestSnippetGenerator_ecma5: ecma5,
|
||||
requestSnippetGenerator_java: javaOkhttp,
|
||||
requestSnippetGenerator_python: python
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
global.SnippetGeneratorPlugin = SnippetGeneratorPlugin;
|
||||
global.SnippetGenerators = {
|
||||
curlBash, curlPwsh, curlCmd, csharp, ecma5, javaOkhttp, python
|
||||
};
|
||||
})(window);
|
||||
@@ -1,314 +0,0 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────── */
|
||||
/* DJB Swagger Testbed overlay */
|
||||
/* - 기본 라이브 응답 표(.live-responses-table) 숨김 (스니펫은 유지) */
|
||||
/* - 실행한 operation 안에 세로 적층 커스텀 응답 패널(.djb-response-grid) */
|
||||
/* - D2Coding 우선 monospace fallback */
|
||||
/* 포탈 통합용. PoC(rinjae-works/swagger-poc/css/overlay.css) 에서 응답 파트만 */
|
||||
/* 발췌·클래스화(단일 ID → 다중 op 대응). */
|
||||
/* ─────────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* D2Coding 은 시스템에 설치된 경우에만 사용 (local()). 미설치 시 fallback 체인 사용. */
|
||||
@font-face {
|
||||
font-family: "D2Coding";
|
||||
src: local("D2Coding"),
|
||||
local("D2Coding-Regular"),
|
||||
local("D2Coding ligature");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--djb-mono: "D2Coding", "Menlo", "Consolas", "Courier New", monospace;
|
||||
--djb-border: #d0d7de;
|
||||
--djb-bg: #ffffff;
|
||||
--djb-bg-alt: #f6f8fa;
|
||||
--djb-text: #1f2328;
|
||||
--djb-text-muted: #6e7781;
|
||||
--djb-ok: #1f883d;
|
||||
--djb-warn: #bf8700;
|
||||
--djb-err: #cf222e;
|
||||
--djb-accent: #0969da;
|
||||
}
|
||||
|
||||
/* ─── Swagger UI 기본 응답영역: 문서화 응답 샘플만 노출 ────────────────────────
|
||||
응답 영역(.responses-wrapper)은 표시하되(= 상태코드별 응답 예제/스키마 = "응답 샘플"),
|
||||
실행 결과 라이브 블록은 숨긴다:
|
||||
- 요청 스니펫(.request-snippets) → 상시 다크 패널(#djb-snippet-panel)로 대체
|
||||
- 라이브 응답(Curl/Request URL/Server response + .live-responses-table)
|
||||
= .responses-inner 의 직접 자식 <div> → 커스텀 그리드(.djb-response-grid)로 대체
|
||||
문서화 응답 예제는 .responses-inner > table.responses-table 이라 <div> 숨김과 구분됨.
|
||||
최종 순서: 파라미터 → 요청 본문 → Execute → 요청 스니펫(패널) → 응답 샘플(표) → 응답 그리드. */
|
||||
.swagger-ui .request-snippets { display: none !important; }
|
||||
.swagger-ui .responses-inner > div { display: none !important; }
|
||||
.swagger-ui .live-responses-table { display: none !important; }
|
||||
|
||||
/* ─── 상시 요청 스니펫 패널 (PoC overlay.css #djb-snippet-panel) ─────────────── */
|
||||
.swagger-ui #djb-snippet-panel {
|
||||
margin: 16px;
|
||||
padding: 12px 16px 16px;
|
||||
background: var(--djb-bg);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-hint {
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-copy {
|
||||
margin-left: auto;
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--djb-bg-alt);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-copy:hover { background: #eaeef2; }
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--djb-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab:hover { color: var(--djb-text); }
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-tab.active {
|
||||
color: var(--djb-accent);
|
||||
border-color: var(--djb-border);
|
||||
background: var(--djb-bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border-radius: 6px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* 한글 라벨이 길어도 깨지지 않도록 */
|
||||
.swagger-ui .opblock .opblock-summary-path,
|
||||
.swagger-ui .opblock-tag,
|
||||
.swagger-ui table thead tr th,
|
||||
.swagger-ui .parameter__name,
|
||||
.swagger-ui .parameter__type {
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
/* ─── 커스텀 응답 그리드: 헤더 → 본문 → 메타 세로 적층 (opblock 마다 1개) ── */
|
||||
.swagger-ui .djb-response-grid {
|
||||
margin: 16px;
|
||||
padding: 16px;
|
||||
background: var(--djb-bg);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"headers"
|
||||
"body"
|
||||
"meta";
|
||||
gap: 12px;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
.swagger-ui .djb-response-grid.djb-error { border-color: var(--djb-err); background: #fff8f8; }
|
||||
|
||||
.swagger-ui .djb-response-grid .cell-headers { grid-area: headers; min-width: 0; }
|
||||
.swagger-ui .djb-response-grid .cell-body { grid-area: body; min-width: 0; }
|
||||
.swagger-ui .djb-response-grid .cell-meta {
|
||||
grid-area: meta;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px dashed var(--djb-border);
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .cell-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--djb-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .empty {
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 13px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* Header 테이블 */
|
||||
.swagger-ui .djb-response-grid .hdr-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table th,
|
||||
.swagger-ui .djb-response-grid .hdr-table td {
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
padding: 6px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-all;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table thead th {
|
||||
background: var(--djb-bg-alt);
|
||||
font-weight: 600;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table td.hdr-name {
|
||||
font-weight: 600;
|
||||
color: var(--djb-text);
|
||||
width: 40%;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .hdr-table td.hdr-val {
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12px;
|
||||
color: var(--djb-text);
|
||||
}
|
||||
|
||||
/* Body 탭 */
|
||||
.swagger-ui .djb-response-grid .body-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--djb-border);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-tab {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--djb-text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-tab:hover { color: var(--djb-text); }
|
||||
.swagger-ui .djb-response-grid .body-tab.active {
|
||||
color: var(--djb-accent);
|
||||
border-color: var(--djb-border);
|
||||
background: var(--djb-bg);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.swagger-ui .djb-response-grid .body-panels {
|
||||
position: relative;
|
||||
min-height: 80px;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-panel {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0d1117;
|
||||
color: #e6edf3;
|
||||
border-radius: 6px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .body-panel.active { display: block; }
|
||||
.swagger-ui .djb-response-grid .mono { font-family: var(--djb-mono); }
|
||||
|
||||
/* Meta 칩 / 시간 */
|
||||
.swagger-ui .djb-response-grid .status-chip {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-family: var(--djb-mono);
|
||||
color: #fff;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .status-chip.ok { background: var(--djb-ok); }
|
||||
.swagger-ui .djb-response-grid .status-chip.warn { background: var(--djb-warn); }
|
||||
.swagger-ui .djb-response-grid .status-chip.err { background: var(--djb-err); }
|
||||
.swagger-ui .djb-response-grid .status-chip.neutral { background: var(--djb-text-muted); }
|
||||
|
||||
.swagger-ui .djb-response-grid .time {
|
||||
font-family: var(--djb-mono);
|
||||
font-size: 12px;
|
||||
color: var(--djb-text-muted);
|
||||
}
|
||||
|
||||
/* ─── 검증 실패 목록 (djb-swagger-validator.js) ─────────────────────────────── */
|
||||
.swagger-ui .djb-response-grid .verr-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-item {
|
||||
padding: 8px 10px;
|
||||
margin-bottom: 6px;
|
||||
background: #fff8f8;
|
||||
border: 1px solid #ffd7d5;
|
||||
border-left: 3px solid var(--djb-err);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-path {
|
||||
font-family: var(--djb-mono);
|
||||
font-weight: 700;
|
||||
color: var(--djb-err);
|
||||
}
|
||||
.swagger-ui .djb-response-grid .verr-msg { color: var(--djb-text); }
|
||||
.swagger-ui .djb-response-grid .verr-val {
|
||||
display: inline-block;
|
||||
margin-top: 2px;
|
||||
padding: 1px 6px;
|
||||
background: var(--djb-bg-alt);
|
||||
border: 1px solid var(--djb-border);
|
||||
border-radius: 3px;
|
||||
color: var(--djb-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ─── 다크 코드 패널 텍스트 선택 가독성 ──────────────────────────────────────
|
||||
기본 ::selection 색이 어두운 배경(#0d1117)과 겹쳐 선택한 글자가 안 보이므로,
|
||||
선택 영역 배경/글자색을 명시한다. (요청 스니펫 코드 + 응답 본문 패널) */
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code::selection,
|
||||
.swagger-ui #djb-snippet-panel .djb-sn-code ::selection,
|
||||
.swagger-ui .djb-response-grid .body-panel::selection,
|
||||
.swagger-ui .djb-response-grid .body-panel ::selection {
|
||||
background: #2f5c9e;
|
||||
color: #ffffff;
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
/*!
|
||||
* DjbSwaggerValidator — Execute 전에 요청 파라미터(header/query)와 요청 본문(body)을
|
||||
* OpenAPI 스키마로 검증하는 경량 JSON Schema 검증기.
|
||||
* (PoC rinjae-works/swagger-poc/js/validator.js 를 포탈용으로 이식)
|
||||
*
|
||||
* 지원 키워드: type, required, properties, items, $ref, maxLength, minLength,
|
||||
* maximum, minimum, pattern, enum
|
||||
* 지원 type: string, integer, number, object, array, boolean
|
||||
*
|
||||
* 사용:
|
||||
* var r = DjbSwaggerValidator.validateOperation(operation, parsedRequest, rootSpec);
|
||||
* if (!r.ok) console.log(r.errors); // [{ path, message, value }]
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
function resolveRef(spec, ref) {
|
||||
if (ref.indexOf("#/") !== 0) return null;
|
||||
var parts = ref.slice(2).split("/");
|
||||
var cur = spec;
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (cur == null) return null;
|
||||
cur = cur[decodeURIComponent(parts[i]).replace(/~1/g, "/").replace(/~0/g, "~")];
|
||||
}
|
||||
return cur || null;
|
||||
}
|
||||
|
||||
function deref(spec, schema, seen) {
|
||||
if (!schema || typeof schema !== "object") return schema;
|
||||
if (schema.$ref) {
|
||||
if (seen && seen.indexOf(schema.$ref) !== -1) return {};
|
||||
var next = resolveRef(spec, schema.$ref);
|
||||
var seen2 = (seen || []).concat([schema.$ref]);
|
||||
return deref(spec, next, seen2);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
function typeMatches(value, type) {
|
||||
if (value === null || value === undefined) return false;
|
||||
switch (type) {
|
||||
case "string": return typeof value === "string";
|
||||
case "integer": return typeof value === "number" && isFinite(value) && Math.floor(value) === value;
|
||||
case "number": return typeof value === "number" && !isNaN(value);
|
||||
case "boolean": return typeof value === "boolean";
|
||||
case "array": return Array.isArray(value);
|
||||
case "object": return typeof value === "object" && !Array.isArray(value);
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
function pushErr(errors, path, message, value) {
|
||||
errors.push({ path: path || "/", message: message, value: value });
|
||||
}
|
||||
|
||||
function checkSchema(spec, schema, value, path, errors) {
|
||||
if (!schema) return;
|
||||
schema = deref(spec, schema);
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
// required 는 상위 object 단계에서 확인. 여기서는 통과.
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema.type && !typeMatches(value, schema.type)) {
|
||||
var actual = typeof value === "object" ? (Array.isArray(value) ? "array" : "object") : typeof value;
|
||||
pushErr(errors, path, '타입이 "' + schema.type + '" 이어야 합니다 (현재: ' + actual + ")", value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.enum) && schema.enum.indexOf(value) === -1) {
|
||||
pushErr(errors, path, "허용된 값이 아닙니다 (허용: " + schema.enum.join(", ") + ")", value);
|
||||
}
|
||||
|
||||
if (schema.type === "string" || typeof value === "string") {
|
||||
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
|
||||
pushErr(errors, path, "최대 " + schema.maxLength + " 자까지 허용됩니다 (현재: " + value.length + "자)", value);
|
||||
}
|
||||
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
||||
pushErr(errors, path, "최소 " + schema.minLength + " 자 이상이어야 합니다 (현재: " + value.length + "자)", value);
|
||||
}
|
||||
if (schema.pattern) {
|
||||
try {
|
||||
if (!new RegExp(schema.pattern).test(value)) {
|
||||
pushErr(errors, path, "형식이 올바르지 않습니다 (pattern: " + schema.pattern + ")", value);
|
||||
}
|
||||
} catch (e) { /* invalid regex in spec — skip */ }
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === "integer" || schema.type === "number" || typeof value === "number") {
|
||||
if (typeof schema.maximum === "number" && value > schema.maximum) {
|
||||
pushErr(errors, path, "최대값 " + schema.maximum + " 이하여야 합니다 (현재: " + value + ")", value);
|
||||
}
|
||||
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
||||
pushErr(errors, path, "최소값 " + schema.minimum + " 이상이어야 합니다 (현재: " + value + ")", value);
|
||||
}
|
||||
}
|
||||
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
var requiredList = Array.isArray(schema.required) ? schema.required : [];
|
||||
requiredList.forEach(function (reqKey) {
|
||||
if (value[reqKey] === undefined || value[reqKey] === null || value[reqKey] === "") {
|
||||
pushErr(errors, path + "/" + reqKey, "필수 항목이 누락되었습니다", undefined);
|
||||
}
|
||||
});
|
||||
Object.keys(schema.properties).forEach(function (key) {
|
||||
if (value[key] !== undefined && value[key] !== null) {
|
||||
checkSchema(spec, schema.properties[key], value[key], path + "/" + key, errors);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (schema.type === "array" && schema.items && Array.isArray(value)) {
|
||||
value.forEach(function (item, idx) {
|
||||
checkSchema(spec, schema.items, item, path + "/" + idx, errors);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Swagger UI 의 request 객체에서 header/query/body 를 추출해 operation 정의로 검증.
|
||||
*
|
||||
* @param {Object} operation spec.paths[path][method] 객체
|
||||
* @param {Object} req { url, method, headers, body, ... }
|
||||
* @param {Object} rootSpec $ref 해석을 위한 전체 OpenAPI 문서(json)
|
||||
* @returns {{ ok:boolean, errors:Array<{path,message,value}> }}
|
||||
*/
|
||||
function validateOperation(operation, req, rootSpec) {
|
||||
var errors = [];
|
||||
if (!operation) return { ok: true, errors: errors };
|
||||
|
||||
// 1. parameters 검증 (header / query). path 는 Swagger 가 이미 치환 → 건너뜀.
|
||||
var params = Array.isArray(operation.parameters) ? operation.parameters : [];
|
||||
for (var i = 0; i < params.length; i++) {
|
||||
var p = deref(rootSpec, params[i]);
|
||||
var where = p.in;
|
||||
var name = p.name;
|
||||
var schema = p.schema ? deref(rootSpec, p.schema) : { type: "string" };
|
||||
|
||||
var value;
|
||||
if (where === "header") {
|
||||
var headers = req.headers || {};
|
||||
var matchKey = Object.keys(headers).filter(function (k) {
|
||||
return k.toLowerCase() === String(name).toLowerCase();
|
||||
})[0];
|
||||
value = matchKey ? headers[matchKey] : undefined;
|
||||
} else if (where === "query") {
|
||||
try {
|
||||
var u = new URL(req.url, "http://_local");
|
||||
value = u.searchParams.get(name);
|
||||
} catch (e) { value = undefined; }
|
||||
} else {
|
||||
continue; // path/cookie 등은 건너뜀
|
||||
}
|
||||
|
||||
if (p.required && (value === undefined || value === "" || value === null)) {
|
||||
pushErr(errors, (where === "query" ? "query" : "header") + "[" + name + "]",
|
||||
(where === "query" ? "필수 쿼리 파라미터가" : "필수 헤더가") + " 누락되었습니다", undefined);
|
||||
continue;
|
||||
}
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
var coerced = (schema.type === "integer" || schema.type === "number") ? Number(value) : String(value);
|
||||
checkSchema(rootSpec, schema, coerced, (where === "query" ? "query" : "header") + "[" + name + "]", errors);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. requestBody 검증
|
||||
if (operation.requestBody) {
|
||||
var rb = deref(rootSpec, operation.requestBody);
|
||||
if (rb.required && (req.body === undefined || req.body === null || req.body === "")) {
|
||||
pushErr(errors, "body", "요청 본문이 비어 있습니다", undefined);
|
||||
} else if (req.body) {
|
||||
var contentTypes = Object.keys(rb.content || {});
|
||||
var ctHeader = (req.headers && (req.headers["Content-Type"] || req.headers["content-type"])) || contentTypes[0];
|
||||
var matchedCt = contentTypes.filter(function (c) {
|
||||
return ctHeader && ctHeader.toLowerCase().indexOf(c.split(";")[0].toLowerCase()) !== -1;
|
||||
})[0] || contentTypes[0];
|
||||
var bodySchema = (matchedCt && rb.content[matchedCt]) ? deref(rootSpec, rb.content[matchedCt].schema) : null;
|
||||
|
||||
var parsed = req.body;
|
||||
if (typeof parsed === "string") {
|
||||
try { parsed = JSON.parse(parsed); }
|
||||
catch (e) {
|
||||
pushErr(errors, "body", "JSON 파싱 실패: " + e.message, req.body);
|
||||
return { ok: false, errors: errors };
|
||||
}
|
||||
}
|
||||
if (bodySchema) {
|
||||
checkSchema(rootSpec, bodySchema, parsed, "body", errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: errors.length === 0, errors: errors };
|
||||
}
|
||||
|
||||
global.DjbSwaggerValidator = {
|
||||
validateOperation: validateOperation,
|
||||
_internal: { resolveRef: resolveRef, deref: deref, checkSchema: checkSchema }
|
||||
};
|
||||
})(window);
|
||||
@@ -1,7 +1,7 @@
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
*,
|
||||
@@ -12,5 +12,4 @@ html {
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,79 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<title>Swagger UI: OAuth2 Redirect</title>
|
||||
</head>
|
||||
<body>
|
||||
<script src="oauth2-redirect.js"></script>
|
||||
<script>
|
||||
'use strict';
|
||||
function run () {
|
||||
var oauth2 = window.opener.swaggerUIRedirectOauth2;
|
||||
var sentState = oauth2.state;
|
||||
var redirectUrl = oauth2.redirectUrl;
|
||||
var isValid, qp, arr;
|
||||
|
||||
if (/code|token|error/.test(window.location.hash)) {
|
||||
qp = window.location.hash.substring(1).replace('?', '&');
|
||||
} else {
|
||||
qp = location.search.substring(1);
|
||||
}
|
||||
|
||||
arr = qp.split("&");
|
||||
arr.forEach(function (v,i,_arr) { _arr[i] = '"' + v.replace('=', '":"') + '"';});
|
||||
qp = qp ? JSON.parse('{' + arr.join() + '}',
|
||||
function (key, value) {
|
||||
return key === "" ? value : decodeURIComponent(value);
|
||||
}
|
||||
) : {};
|
||||
|
||||
isValid = qp.state === sentState;
|
||||
|
||||
if ((
|
||||
oauth2.auth.schema.get("flow") === "accessCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorizationCode" ||
|
||||
oauth2.auth.schema.get("flow") === "authorization_code"
|
||||
) && !oauth2.auth.code) {
|
||||
if (!isValid) {
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "warning",
|
||||
message: "Authorization may be unsafe, passed state was changed in server. The passed state wasn't returned from auth server."
|
||||
});
|
||||
}
|
||||
|
||||
if (qp.code) {
|
||||
delete oauth2.state;
|
||||
oauth2.auth.code = qp.code;
|
||||
oauth2.callback({auth: oauth2.auth, redirectUrl: redirectUrl});
|
||||
} else {
|
||||
let oauthErrorMsg;
|
||||
if (qp.error) {
|
||||
oauthErrorMsg = "["+qp.error+"]: " +
|
||||
(qp.error_description ? qp.error_description+ ". " : "no accessCode received from the server. ") +
|
||||
(qp.error_uri ? "More info: "+qp.error_uri : "");
|
||||
}
|
||||
|
||||
oauth2.errCb({
|
||||
authId: oauth2.auth.name,
|
||||
source: "auth",
|
||||
level: "error",
|
||||
message: oauthErrorMsg || "[Authorization failed]: no accessCode received from the server."
|
||||
});
|
||||
}
|
||||
} else {
|
||||
oauth2.callback({auth: oauth2.auth, token: qp, isValid: isValid, redirectUrl: redirectUrl});
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
if (document.readyState !== 'loading') {
|
||||
run();
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
run();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
// Color Palette - Vibrant and Modern
|
||||
// Primary colors
|
||||
$primary-color: #0049B4;
|
||||
$primary-blue: #0049b4; // Vibrant blue
|
||||
$secondary-blue: #c3dfea; // Deep blue
|
||||
$accent-cyan: #00D4FF; // Sky blue accent
|
||||
|
||||
@@ -8,9 +8,12 @@
|
||||
|
||||
// Float animation
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(0deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-30px) rotate(10deg);
|
||||
}
|
||||
@@ -18,9 +21,12 @@
|
||||
|
||||
// Bounce animation
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
@@ -32,6 +38,7 @@
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -44,6 +51,7 @@
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -56,6 +64,7 @@
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
@@ -68,6 +77,7 @@
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
@@ -79,6 +89,7 @@
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -90,6 +101,7 @@
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -101,6 +113,7 @@
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
@@ -111,9 +124,11 @@
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
@@ -124,6 +139,7 @@
|
||||
0% {
|
||||
transform: translateX(-100%) rotate(45deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(100%) rotate(45deg);
|
||||
}
|
||||
@@ -134,24 +150,31 @@
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: rotate(14deg);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
@@ -163,9 +186,11 @@
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -178,6 +203,7 @@
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
@@ -190,6 +216,7 @@
|
||||
transform: scale(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(4);
|
||||
opacity: 0;
|
||||
@@ -201,9 +228,11 @@
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
@@ -214,6 +243,27 @@
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-15px);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-slow {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.floating-fast {
|
||||
animation: float 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 2s infinite;
|
||||
}
|
||||
@@ -222,6 +272,37 @@
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes drawLine {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.draw-line {
|
||||
stroke-dasharray: 1000;
|
||||
stroke-dashoffset: 1000;
|
||||
animation: drawLine 3s ease-out forwards;
|
||||
}
|
||||
|
||||
.pulsing {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -325,4 +406,4 @@
|
||||
&:hover::before {
|
||||
transform: translateX(200%) rotate(45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,28 +9,30 @@
|
||||
|
||||
// FAQ Accordion - Figma Design
|
||||
.faq-accordion {
|
||||
background: #F6F9FB;
|
||||
border-radius: $border-radius-lg;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: $spacing-2xl;
|
||||
|
||||
@include respond-to('sm') {
|
||||
background: #FFFFFF;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border-bottom: 1px solid #DADADA;
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: $transition-base;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
// Active state - when accordion is open
|
||||
&.active {
|
||||
.faq-question {
|
||||
background: #4685ef;
|
||||
border-color: #e8dddd;
|
||||
|
||||
.faq-question-q,
|
||||
.faq-question-text {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.faq-icon {
|
||||
color: #fff;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
@@ -42,33 +44,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Question row - Figma: 18px bold, #212529
|
||||
// Question row
|
||||
.faq-question {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
font-size: 18px;
|
||||
font-weight: $font-weight-bold;
|
||||
color: #212529;
|
||||
min-height: 47px;
|
||||
height: auto;
|
||||
padding: 8px 35px 8px 21px;
|
||||
background: #fff;
|
||||
border: 1px solid #dedede;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: $transition-base;
|
||||
gap: 95px;
|
||||
min-height: 54px;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
font-size: 16px;
|
||||
gap: $spacing-lg;
|
||||
padding: 12px 16px;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary-blue, 0.02);
|
||||
.faq-question-q {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #555;
|
||||
margin-right: 15px;
|
||||
line-height: 1;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
font-size: 16px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.faq-question-text {
|
||||
flex: 1;
|
||||
line-height: $line-height-normal;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
line-height: normal;
|
||||
word-break: keep-all;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// Chevron icon
|
||||
@@ -79,7 +96,7 @@
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: #212529;
|
||||
color: #000;
|
||||
transition: transform 0.3s ease;
|
||||
|
||||
svg {
|
||||
@@ -89,49 +106,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Answer section - white background
|
||||
// Answer section
|
||||
.faq-answer {
|
||||
display: none;
|
||||
background: #F6F9FB;
|
||||
padding: $spacing-lg 20px $spacing-xl;
|
||||
border-top: none;
|
||||
|
||||
@include respond-to('sm') {
|
||||
background: #FFFFFF;
|
||||
}
|
||||
padding: 19px 21px 25px 21px;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
padding: $spacing-md $spacing-lg $spacing-lg;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.faq-answer-content {
|
||||
font-size: 18px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: #515151;
|
||||
background-color: #ffffff;
|
||||
padding: $spacing-lg;
|
||||
line-height: $line-height-loose;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
line-height: 1.6;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// Content formatting
|
||||
p {
|
||||
margin-bottom: $spacing-md;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: $spacing-sm 0;
|
||||
padding-left: $spacing-lg;
|
||||
ul,
|
||||
ol {
|
||||
margin: 8px 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: $spacing-xs;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
a {
|
||||
@@ -225,6 +234,7 @@
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
@@ -306,4 +316,4 @@
|
||||
animation: slideDown 0.3s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,7 @@
|
||||
|
||||
// ── 댓글 목록 박스 (설계서 ① 영역) ─────────────────────────
|
||||
.djb-comment-list-box {
|
||||
padding: 24px 28px;
|
||||
background-color: #FFFFFF;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 8px;
|
||||
min-height: 120px;
|
||||
margin-bottom: 28px;
|
||||
@@ -64,16 +62,16 @@
|
||||
|
||||
// ── 댓글 한 건 (설계서 작성자 + 날짜 + Contents) ───────────
|
||||
.djb-comment-item {
|
||||
padding: 18px 0;
|
||||
border-bottom: 1px dashed #E5E7EB;
|
||||
padding: 40px 0;
|
||||
border-bottom: 1px solid #DFDFDF;
|
||||
|
||||
&:first-child {
|
||||
padding-top: 4px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
&--admin .djb-comment-writer {
|
||||
@@ -94,21 +92,21 @@
|
||||
}
|
||||
|
||||
.djb-comment-writer {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.djb-comment-admin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
background-color: #DBEAFE;
|
||||
color: #1D4ED8;
|
||||
padding: 3px 8px;
|
||||
background-color: #ecf1ff;
|
||||
color: #2a69de;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.djb-comment-meta-right {
|
||||
@@ -123,29 +121,32 @@
|
||||
}
|
||||
|
||||
.djb-comment-delete-btn {
|
||||
background: none;
|
||||
border: 1px solid #E5E7EB;
|
||||
border-radius: 4px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
color: #6B7280;
|
||||
background: #fbe7e9;
|
||||
border: 1px solid #f1c2d6;
|
||||
border-radius: 5px;
|
||||
padding: 3px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: #bb1026;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 20px;
|
||||
|
||||
&:hover {
|
||||
background-color: #FEE2E2;
|
||||
border-color: #FCA5A5;
|
||||
color: #B91C1C;
|
||||
background-color: #f1c2d6; // Changed from darken(#fbe7e9, 5%) to fix warning
|
||||
}
|
||||
}
|
||||
|
||||
.djb-comment-body {
|
||||
padding: 12px 16px;
|
||||
background-color: #F3F4F6;
|
||||
border-radius: 6px;
|
||||
padding: 24px;
|
||||
background-color: #f4f5f9;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #1F2937;
|
||||
line-height: 20px;
|
||||
color: #000;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -167,31 +168,31 @@ textarea.djb-comment-input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid #D1D5DB;
|
||||
border-radius: 8px;
|
||||
background-color: #F9FAFB;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid #dfdfdf;
|
||||
border-radius: 10px;
|
||||
background-color: #ffffff;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #1F2937;
|
||||
color: #000;
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
min-height: 103px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&::placeholder {
|
||||
color: #9CA3AF;
|
||||
color: #7f8a95;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: #9CA3AF;
|
||||
border-color: #2a69de;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: #1D4ED8;
|
||||
border-color: #2a69de;
|
||||
background-color: #FFFFFF;
|
||||
box-shadow: 0 0 0 3px rgba(29, 78, 216, 0.15);
|
||||
box-shadow: 0 0 0 3px rgba(42, 105, 222, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,28 +210,22 @@ textarea.djb-comment-input {
|
||||
}
|
||||
|
||||
button.djb-comment-submit {
|
||||
display: inline-block;
|
||||
min-width: 140px;
|
||||
padding: 12px 28px;
|
||||
background-color: #1D4ED8;
|
||||
color: #FFFFFF;
|
||||
border: 1px solid #1D4ED8;
|
||||
border-radius: 8px;
|
||||
font-size: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 149px;
|
||||
height: 39px;
|
||||
background-color: #ffffff;
|
||||
color: #4685ef;
|
||||
border: 1px solid #2a69de;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.2px;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 6px rgba(29, 78, 216, 0.18);
|
||||
transition: background-color 0.15s ease, box-shadow 0.15s ease, transform 0.05s ease;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: #1E40AF;
|
||||
border-color: #1E40AF;
|
||||
box-shadow: 0 3px 10px rgba(29, 78, 216, 0.28);
|
||||
background-color: #f8faff;
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -238,9 +233,9 @@ button.djb-comment-submit {
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background-color: #94A3B8;
|
||||
border-color: #94A3B8;
|
||||
box-shadow: none;
|
||||
background-color: #f4f5f9;
|
||||
border-color: #dfdfdf;
|
||||
color: #7f8a95;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
@@ -253,4 +248,4 @@ button.djb-comment-submit {
|
||||
text-align: center;
|
||||
color: #6B7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
label {
|
||||
display: block;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-medium;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-dark;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
@@ -453,6 +453,7 @@ select.form-control {
|
||||
|
||||
.form-field-wrapper {
|
||||
flex: 1;
|
||||
|
||||
@include respond-to('sm') {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -757,6 +758,7 @@ select.form-control {
|
||||
}
|
||||
|
||||
.btn {
|
||||
|
||||
// Figma 모바일 (1291-3170): 144px × 40px, border-radius 8px, 14px bold
|
||||
@include respond-to('sm') {
|
||||
width: 144px !important;
|
||||
@@ -960,3 +962,71 @@ select.form-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// DJB Common Form Elements (Global Reusable Styles)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.djb-label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.required {
|
||||
color: #e53e3e;
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.djb-input {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid #DFDFDF;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
transition: border-color 0.2s;
|
||||
background-color: #fcfcfc;
|
||||
|
||||
&::placeholder {
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2a69de;
|
||||
}
|
||||
}
|
||||
|
||||
.djb-textarea {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border: 1px solid #DFDFDF;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
resize: none;
|
||||
transition: border-color 0.2s;
|
||||
min-height: 250px;
|
||||
|
||||
&::placeholder {
|
||||
color: #94A3B8;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #2a69de;
|
||||
}
|
||||
}
|
||||
|
||||
.djb-char-counter-wrapper {
|
||||
text-align: right;
|
||||
margin-top: 8px;
|
||||
|
||||
.char-counter {
|
||||
font-size: 13px;
|
||||
color: #7f8a95;
|
||||
}
|
||||
}
|
||||
@@ -18,46 +18,76 @@
|
||||
&.authenticated {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
|
||||
.login-btn{
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-color);
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
&:hover{
|
||||
background-color: var(--accent-light);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
background-color: var(--accent-light);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// Login Button
|
||||
// ===========================
|
||||
.login-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
color: $text-dark;
|
||||
text-decoration: none;
|
||||
border: 2px solid $primary-blue;
|
||||
border-radius: $border-radius-full;
|
||||
font-weight: $font-weight-semibold;
|
||||
font-size: $font-size-sm;
|
||||
transition: $transition-base;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-color);
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
// Box style for Figma design (node-id: 721-1489)
|
||||
&-box {
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px; // 아이콘-텍스트 간격
|
||||
padding: 6px 16px;
|
||||
color: #212529; // 강조 텍스트 색상
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-family: $font-family-primary;
|
||||
font-weight: $font-weight-semibold; // 600
|
||||
font-size: 16px;
|
||||
background: transparent;
|
||||
transition: $transition-base;
|
||||
&:hover {
|
||||
background-color: var(--accent-light);
|
||||
}
|
||||
}
|
||||
|
||||
.user-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-signup {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #FFFFFF;
|
||||
background-color: var(--primary-color);
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 73, 180, 0.15);
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--secondary-color);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 73, 180, 0.25);
|
||||
color:white;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Box style for Figma design (node-id: 721-1489)
|
||||
.login-btn-box {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--primary-color);
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
.user-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ $pagination-text-disabled: #adb5bd;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: $pagination-gap;
|
||||
margin-top:50px;
|
||||
|
||||
// Page navigation buttons (first, prev, next, last)
|
||||
.page-first,
|
||||
|
||||
@@ -275,10 +275,138 @@
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Board Table - Standard BBS design (Figma: 165-125)
|
||||
// Used in: Notice, Q&A, FAQ etc.
|
||||
// -----------------------------------------------------------------------------
|
||||
.board-table {
|
||||
width: 100%;
|
||||
border-top: 1px solid #c4c7c8;
|
||||
|
||||
&-wrapper {
|
||||
overflow-x: auto;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
// Table Header - Gray background (#f9f9f9)
|
||||
.board-table-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #f9f9f9;
|
||||
height: 50px;
|
||||
padding: 0 45px;
|
||||
border-bottom: 1px solid #dedede;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-cell {
|
||||
font-size: 14px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 500;
|
||||
color: #000;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// Table Body
|
||||
.board-table-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// Table Row
|
||||
.board-table-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 50px;
|
||||
padding: 0 45px;
|
||||
border-bottom: 1px solid #dedede;
|
||||
transition: background-color 0.2s;
|
||||
background: #ffffff;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8fbfe;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
height: auto;
|
||||
padding: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.row-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 400;
|
||||
color: #000;
|
||||
white-space: nowrap;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
justify-content: flex-start;
|
||||
width: 100% !important;
|
||||
|
||||
&::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
margin-right: 10px;
|
||||
min-width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
&--title {
|
||||
justify-content: flex-start;
|
||||
white-space: normal;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
&::before { display: none; }
|
||||
}
|
||||
|
||||
.notice-title-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
width: 100%;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
// Mobile number prefix [1] [2] etc
|
||||
.notice-number {
|
||||
display: none;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&--number {
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// List Table - Modern design with colored header (Figma: 984-2173)
|
||||
// Used in: User management, API key list, etc.
|
||||
// Refined: Clean Minimal sizing with original KJB colors
|
||||
// -----------------------------------------------------------------------------
|
||||
.list-table {
|
||||
width: 100%;
|
||||
@@ -459,17 +587,17 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 $spacing-sm;
|
||||
height: 30px;
|
||||
padding: 0 $spacing-md;
|
||||
height: 28px;
|
||||
white-space: nowrap;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: 13px;
|
||||
border-radius: 5px;
|
||||
font-size: 12px;
|
||||
font-weight: $font-weight-regular;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
color: #000000;
|
||||
border: none;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: $transition-base;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
@@ -482,35 +610,42 @@
|
||||
|
||||
// Button variants
|
||||
&--default {
|
||||
background-color: #DADADA;
|
||||
background-color: #f5f5f4;
|
||||
border-color: #bdc7cf;
|
||||
color: #64748b;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#DADADA, 5%);
|
||||
background-color: darken(#f5f5f4, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&--primary {
|
||||
background-color: #A4D6EA;
|
||||
background-color: #ecf0fa;
|
||||
border-color: #4685ef;
|
||||
color: #2a69de;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#A4D6EA, 5%);
|
||||
background-color: darken(#ecf0fa, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&--secondary {
|
||||
background-color: #CDD4F0;
|
||||
background-color: #f5f5f4;
|
||||
border-color: #bdc7cf;
|
||||
color: #64748b;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#CDD4F0, 5%);
|
||||
background-color: darken(#f5f5f4, 5%);
|
||||
}
|
||||
}
|
||||
|
||||
&--danger {
|
||||
background-color: #F8D7DA;
|
||||
color: #B02A37;
|
||||
background-color: #fbe7e9;
|
||||
border-color: #f1c2d6;
|
||||
color: #bb1026;
|
||||
|
||||
&:hover {
|
||||
background-color: darken(#F8D7DA, 5%);
|
||||
background-color: darken(#fbe7e9, 5%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// Footer Styles - Figma Design Implementation
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/* 원래 있던 Footer 스타일 (주석 처리)
|
||||
.global-footer {
|
||||
background: #FFFFFF;
|
||||
border-top: 1px solid #E5E7EB;
|
||||
@@ -142,6 +143,143 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/* 피그마 시안 적용 다크 모드 Footer 스타일 */
|
||||
.global-footer {
|
||||
background-color: rgb(15, 23, 42);
|
||||
color: rgb(100, 116, 139);
|
||||
padding: 60px 0px;
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 40px;
|
||||
|
||||
@include respond-to('md') {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 32px;
|
||||
padding: 32px 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Left Section
|
||||
.footer-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
|
||||
.footer-logo {
|
||||
width: 114px;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.footer-link {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #9CA3AF;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
color: #FFFFFF;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-separator {
|
||||
color: #9CA3AF;
|
||||
font-size: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-copyright {
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: #9CA3AF;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Right Section
|
||||
.footer-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-end;
|
||||
|
||||
@include respond-to('md') {
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-related-sites {
|
||||
.related-sites-select {
|
||||
width: 240px;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
color: #9CA3AF;
|
||||
background: rgb(30, 41, 59);
|
||||
border: 1px solid rgb(71, 85, 105);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L6 6L11 1' stroke='%239CA3AF' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 16px center;
|
||||
padding-right: 40px;
|
||||
|
||||
&:hover {
|
||||
border-color: #FFFFFF;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #FFFFFF;
|
||||
}
|
||||
|
||||
@include respond-to('md') {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer-contact {
|
||||
font-family: "Spoqa Han Sans Neo", "SpoqaHanSans", "Noto Sans KR", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #9CA3AF;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
|
||||
@include respond-to('md') {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile responsive adjustments (sm: 768px)
|
||||
@include respond-to('sm') {
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
// CSS Variables for modern header styling
|
||||
:root {
|
||||
// Enhanced color palette from design
|
||||
--primary-color: #0049b4;
|
||||
--secondary-color: #008ae2;
|
||||
--primary-blue: #4B9BFF;
|
||||
--secondary-blue: #2E7FF7;
|
||||
--accent-cyan: #00D4FF;
|
||||
@@ -16,6 +18,8 @@
|
||||
--accent-orange: #FF6B6B;
|
||||
--accent-green: #6BCF7F;
|
||||
--accent-purple: #A78BFA;
|
||||
--accent-color:#efdcb2;
|
||||
--accent-light: #E9F9FF;
|
||||
|
||||
// Base colors
|
||||
--text-dark: #1A1A2E;
|
||||
@@ -31,6 +35,13 @@
|
||||
--shadow-md: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
--shadow-lg: 0 8px 24px rgba(75, 155, 255, 0.15);
|
||||
--shadow-xl: 0 16px 40px rgba(75, 155, 255, 0.2);
|
||||
--shadow-hover: 0 30px 60px -10px rgba(0, 73, 180, 0.2);
|
||||
|
||||
// border
|
||||
--border-color : #E2E8F0;
|
||||
|
||||
//transition
|
||||
--transition-smooth: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -91,16 +102,28 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: 40px;
|
||||
.survey-label { display: none; }
|
||||
.survey-btn { padding: 4px 12px; font-size: 12px; }
|
||||
|
||||
.survey-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.survey-btn {
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Body offset when survey is active
|
||||
body.design-survey-active {
|
||||
.global-header { margin-top: 48px; }
|
||||
.global-header {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.global-header { margin-top: 40px; }
|
||||
.global-header {
|
||||
margin-top: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,35 +147,63 @@ body.design-survey-active {
|
||||
// Modern Header Structure
|
||||
// ===========================
|
||||
.global-header {
|
||||
position: relative;
|
||||
background-color: #ffffff;
|
||||
z-index: $z-index-header;
|
||||
height: 112px;
|
||||
transition: all 0.3s ease;
|
||||
border-bottom: 1px solid #dddddd;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 80px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(15px);
|
||||
border-bottom: 1px solid rgba(0, 73, 180, 0.08);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
// Index page header with light blue background
|
||||
&.index-header {
|
||||
background-color: #E5F8FF;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.container {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
|
||||
.header-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
height: clamp(44px, 11.73vw, 60px);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: none;
|
||||
|
||||
.container {
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
background-color: #FEFEFE;
|
||||
|
||||
.header-content {
|
||||
padding: 0 clamp(16px, 4.27vw, 24px) 0 clamp(20px, 5.33vw, 28px);
|
||||
height: clamp(44px, 11.73vw, 60px);
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-content {
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 65px;
|
||||
max-width: 1920px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -166,7 +217,6 @@ body.design-survey-active {
|
||||
|
||||
.logo {
|
||||
height: 32px;
|
||||
width: 114px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,13 +225,27 @@ body.design-survey-active {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size:24px;
|
||||
|
||||
.logo-text-bold {
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
|
||||
.logo-text-thin {
|
||||
font-weight: 300;
|
||||
color: var(--secondary-color);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Logo with modern styling
|
||||
.logo {
|
||||
display: block;
|
||||
height: 32px;
|
||||
width: 114px;
|
||||
vertical-align: middle;
|
||||
z-index: 10;
|
||||
|
||||
@@ -381,9 +445,11 @@ body.design-survey-active {
|
||||
&:nth-child(1) {
|
||||
transform: rotate(45deg) translateY(5px);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
transform: rotate(-45deg) translateY(-5px);
|
||||
}
|
||||
@@ -801,14 +867,27 @@ body.design-survey-active {
|
||||
transition: all $transition-fast;
|
||||
}
|
||||
|
||||
&::before { top: -8px; }
|
||||
&::after { top: 8px; }
|
||||
&::before {
|
||||
top: -8px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&.on span {
|
||||
background: transparent;
|
||||
&::before { top: 0; transform: rotate(45deg); }
|
||||
&::after { top: 0; transform: rotate(-45deg); }
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 0;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,7 +927,7 @@ body.design-survey-active {
|
||||
.nav-item {
|
||||
border-bottom: 1px solid $border-gray;
|
||||
|
||||
> a {
|
||||
>a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-lg;
|
||||
@@ -859,7 +938,7 @@ body.design-survey-active {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&.has-submenu > a::after {
|
||||
&.has-submenu>a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: $spacing-lg;
|
||||
@@ -872,7 +951,7 @@ body.design-survey-active {
|
||||
transition: transform $transition-fast;
|
||||
}
|
||||
|
||||
&.expanded > a::after {
|
||||
&.expanded>a::after {
|
||||
transform: translateY(-25%) rotate(-135deg);
|
||||
}
|
||||
|
||||
@@ -885,31 +964,33 @@ body.design-survey-active {
|
||||
|
||||
a {
|
||||
display: block;
|
||||
padding: $spacing-md $spacing-lg $spacing-md ($spacing-lg * 2);
|
||||
color: $text-gray;
|
||||
font-size: $font-size-sm;
|
||||
text-decoration: none;
|
||||
padding: $spacing-md $spacing-lg $spacing-md (
|
||||
$spacing-lg * 2
|
||||
);
|
||||
color: $text-gray;
|
||||
font-size: $font-size-sm;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
background: $white;
|
||||
color: $primary-blue;
|
||||
}
|
||||
&:hover {
|
||||
background: $white;
|
||||
color: $primary-blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.expanded .sub-menu {
|
||||
display: block;
|
||||
}
|
||||
&.expanded .sub-menu {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info_wrap {
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
background: $white;
|
||||
border-top: 1px solid $border-gray;
|
||||
margin-top: auto;
|
||||
// styles for tit1, tit2...
|
||||
}
|
||||
.info_wrap {
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
background: $white;
|
||||
border-top: 1px solid $border-gray;
|
||||
margin-top: auto;
|
||||
// styles for tit1, tit2...
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================
|
||||
@@ -918,16 +999,16 @@ body.design-survey-active {
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 8px;
|
||||
gap: 36px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
// Navigation item with submenu
|
||||
> li {
|
||||
>li {
|
||||
position: relative;
|
||||
|
||||
&.submenu-open {
|
||||
> .nav-link {
|
||||
>.nav-link {
|
||||
background: var(--light-bg);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
@@ -980,16 +1061,12 @@ body.design-survey-active {
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
color: var(--text-dark);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--text-gray);
|
||||
padding: 8px 0;
|
||||
position: relative;
|
||||
transition: var(--transition-smooth);
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-blue);
|
||||
@@ -1022,10 +1099,17 @@ body.design-survey-active {
|
||||
// Desktop Navigation Styles
|
||||
// ===========================
|
||||
@include respond-to('desktop') {
|
||||
.m-only { display: none !important; }
|
||||
.pc-only { display: block !important; }
|
||||
.m-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-mobilemenu { display: none; }
|
||||
.pc-only {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.btn-mobilemenu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-nav.m-nav {
|
||||
position: static;
|
||||
@@ -1038,8 +1122,13 @@ body.design-survey-active {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
|
||||
.nav-header { display: none; }
|
||||
.info_wrap { display: none; }
|
||||
.nav-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.info_wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: flex;
|
||||
@@ -1051,7 +1140,7 @@ body.design-survey-active {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
> a {
|
||||
>a {
|
||||
height: 100%;
|
||||
padding: 0 24px;
|
||||
color: var(--text-dark);
|
||||
@@ -1061,6 +1150,7 @@ body.design-survey-active {
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-blue);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -1074,7 +1164,9 @@ body.design-survey-active {
|
||||
}
|
||||
}
|
||||
|
||||
&.has-submenu > a::after { display: none; }
|
||||
&.has-submenu>a::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1244,4 +1336,4 @@ body.design-survey-active {
|
||||
.header-user-info {
|
||||
display: none; // Hide on mobile, use drawer instead
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,38 +8,128 @@
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
.api-market-container {
|
||||
// display: flex;
|
||||
// max-width: 1240px;
|
||||
// margin: 0 auto;
|
||||
// min-height: 100vh;
|
||||
// background-color: transparent;
|
||||
// position: relative;
|
||||
// margin-bottom: 20px;
|
||||
|
||||
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 60px 40px;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background-color: $white;
|
||||
gap: 60px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
// Hero Section
|
||||
.api-market-hero {
|
||||
background: linear-gradient(to right, #eff9fe, #fcfeff);
|
||||
border: 1px solid #e3e8f0;
|
||||
padding: 60px 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: calc(100vw - 80px);
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-top: 40px;
|
||||
margin-bottom: 60px;
|
||||
|
||||
&__inner {
|
||||
max-width: 1240px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 138px;
|
||||
padding: 0 37px;
|
||||
}
|
||||
|
||||
&__illust {
|
||||
.cloud-image-placeholder {
|
||||
width: 331px;
|
||||
height: 229px;
|
||||
background: url('../img/hero_image_1.png') no-repeat center center;
|
||||
background-size: contain;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
background: #fff;
|
||||
border: 1px solid #d1e6f0;
|
||||
border-radius: 38px;
|
||||
font-size: 13px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 700;
|
||||
color: #1b4ab7;
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #1b4ab7;
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 36px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
font-size: 16px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar Navigation
|
||||
.api-market-sidebar {
|
||||
width: 283px;
|
||||
background-color: #F8FBFD;
|
||||
width: 213px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e3e8f0;
|
||||
border-radius: 20px;
|
||||
padding: $spacing-2xl $spacing-lg;
|
||||
padding: 28px 30px;
|
||||
position: sticky;
|
||||
top: $spacing-lg;
|
||||
height: calc(100vh - 32px);
|
||||
top: 40px;
|
||||
height: fit-content;
|
||||
flex-shrink: 0;
|
||||
margin: $spacing-md;
|
||||
margin-right: 40px;
|
||||
|
||||
@media (max-width: $breakpoint-md) {
|
||||
width: 240px;
|
||||
width: 200px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 60px; // Below mobile header
|
||||
top: 60px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: $shadow-lg;
|
||||
z-index: $z-index-modal;
|
||||
height: calc(100vh - 60px); // Full height minus header
|
||||
height: calc(100vh - 60px);
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
|
||||
@@ -49,67 +139,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
.api-sidebar-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: $spacing-xl;
|
||||
padding-bottom: $spacing-lg;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.api-sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 26px;
|
||||
|
||||
.menu-section {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 1px solid #DDDDDD;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
padding: 16px 10px;
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-regular;
|
||||
color: $text-dark;
|
||||
padding: 0;
|
||||
font-size: 16px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 500;
|
||||
color: #0d0e11;
|
||||
cursor: pointer;
|
||||
transition: $transition-base;
|
||||
transition: color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba($primary-blue, 0.05);
|
||||
color: #2a69de;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: $font-weight-bold;
|
||||
color: $primary-blue;
|
||||
}
|
||||
|
||||
.api-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 $spacing-sm;
|
||||
background-color: rgba($primary-blue, 0.1);
|
||||
color: $primary-blue;
|
||||
font-size: $font-size-xs;
|
||||
font-weight: $font-weight-semibold;
|
||||
border-radius: $border-radius-full;
|
||||
transition: $transition-base;
|
||||
}
|
||||
|
||||
&.active .api-count {
|
||||
background-color: $primary-blue;
|
||||
color: $white;
|
||||
font-weight: 700;
|
||||
color: #2a69de;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,9 +280,8 @@
|
||||
// Main Content Area
|
||||
.api-market-content {
|
||||
flex: 1;
|
||||
padding-top: $spacing-5xl;
|
||||
padding-left: $spacing-sm;
|
||||
padding-right: $spacing-sm;
|
||||
// padding-left: $spacing-sm;
|
||||
// padding-right: $spacing-sm;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -311,7 +370,7 @@
|
||||
// Search Box
|
||||
.api-market-search {
|
||||
position: relative;
|
||||
width: 300px;
|
||||
width: 228px;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
width: 100%;
|
||||
@@ -325,53 +384,41 @@
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
padding: 12px 48px 12px $spacing-md;
|
||||
border: 1px solid $border-gray;
|
||||
border-radius: $border-radius-lg;
|
||||
font-size: $font-size-sm;
|
||||
height: 44px;
|
||||
padding: 0 40px 0 16px;
|
||||
border: 1px solid #dadada;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: $transition-base;
|
||||
|
||||
&:focus {
|
||||
border-color: $primary-blue;
|
||||
box-shadow: 0 0 0 3px rgba($primary-blue, 0.1);
|
||||
border-color: #0049b4;
|
||||
box-shadow: 0 0 0 3px rgba(#0049b4, 0.1);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $text-light;
|
||||
color: #7f8a95;
|
||||
}
|
||||
}
|
||||
|
||||
.search-submit-btn {
|
||||
position: absolute;
|
||||
right: 4px;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: $border-radius-md;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: $transition-base;
|
||||
color: $text-gray;
|
||||
|
||||
&:hover {
|
||||
background: $light-bg;
|
||||
color: $primary-blue;
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
font-size: 18px;
|
||||
display: block;
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -381,101 +428,131 @@
|
||||
// Card Grid
|
||||
.api-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 320px); // 3 cards per row on desktop (고정폭)
|
||||
gap: $spacing-lg;
|
||||
justify-content: center;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
justify-content: flex-start;
|
||||
|
||||
// Tablet view - 2 cards per row (고정폭)
|
||||
@media (max-width: $breakpoint-md) {
|
||||
grid-template-columns: repeat(2, 320px);
|
||||
// Tablet view
|
||||
@media (max-width: 1300px) {
|
||||
grid-template-columns: repeat(2, 312px);
|
||||
}
|
||||
|
||||
// Mobile view - 1 card per row (화면에 맞춤)
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
// Mobile view
|
||||
@media (max-width: $breakpoint-md) {
|
||||
grid-template-columns: 1fr;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
// API Card
|
||||
.api-card {
|
||||
background: $white;
|
||||
border-radius: $border-radius-lg;
|
||||
padding: $spacing-xl;
|
||||
box-shadow: $shadow-sm;
|
||||
background: #FFFFFF;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #DDDDDD;
|
||||
box-shadow: 0px 4px 2px rgba(194, 194, 194, 0.25);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
border: 1px solid $border-gray;
|
||||
width: 100%;
|
||||
height: 312px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease, background 0.3s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0px 8px 12px rgba(194, 194, 194, 0.4);
|
||||
background: linear-gradient(to bottom, #50caff, #2088ff);
|
||||
border: none;
|
||||
|
||||
// Group Info Section (Icon + Name)
|
||||
.api-card-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
.api-card-badge {
|
||||
background-color: #d8f0ff;
|
||||
}
|
||||
|
||||
.api-card-group-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $gray-bg;
|
||||
border-radius: $border-radius-md;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
object-fit: contain;
|
||||
.api-card-title,
|
||||
.api-card-desc {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 16px;
|
||||
color: $primary-blue;
|
||||
&-badge {
|
||||
position: absolute;
|
||||
top: 39px;
|
||||
left: 32px;
|
||||
background-color: #f2f2f3;
|
||||
border-radius: 10px;
|
||||
padding: 2px 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 500;
|
||||
color: #1b4ab7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.api-card-group-name {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-gray;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
&-title {
|
||||
position: absolute;
|
||||
top: 86px;
|
||||
left: 36px;
|
||||
right: 40px;
|
||||
font-size: 18px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 700;
|
||||
color: #000000;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
// API Name (Title)
|
||||
.api-card-name {
|
||||
font-size: $font-size-lg;
|
||||
font-weight: $font-weight-semibold;
|
||||
color: $text-dark;
|
||||
line-height: $line-height-tight;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
&-desc {
|
||||
position: absolute;
|
||||
top: 135px;
|
||||
left: 36px;
|
||||
right: 40px;
|
||||
font-size: 14px;
|
||||
font-family: 'Spoqa Han Sans Neo', sans-serif;
|
||||
font-weight: 400;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
// API Description
|
||||
.api-card-description {
|
||||
font-size: $font-size-sm;
|
||||
color: $text-gray;
|
||||
line-height: $line-height-normal;
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
&-image {
|
||||
position: absolute;
|
||||
bottom: 28px;
|
||||
right: 20px;
|
||||
width: 93px;
|
||||
height: 88px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty State
|
||||
@@ -602,130 +679,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Testbed 앱 선택 패널 (DJPGPT0001)
|
||||
.testbed-app-panel {
|
||||
margin: $spacing-sm 0 $spacing-lg;
|
||||
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 {
|
||||
background: transparent;
|
||||
@@ -956,6 +909,7 @@
|
||||
// Mobile Responsive Styles (sm: 768px)
|
||||
// -----------------------------------------------------------------------------
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
|
||||
// API Detail Tabs - Mobile
|
||||
.api-detail-tabs {
|
||||
gap: 0;
|
||||
@@ -1052,7 +1006,8 @@
|
||||
min-width: 500px; // 테이블 최소 너비 보장
|
||||
margin: $spacing-sm 0;
|
||||
|
||||
th, td {
|
||||
th,
|
||||
td {
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
font-size: $font-size-xs;
|
||||
white-space: nowrap;
|
||||
@@ -1127,4 +1082,4 @@
|
||||
max-width: 100vw;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,6 @@
|
||||
font-weight: $font-weight-bold;
|
||||
color: $text-dark;
|
||||
margin-bottom: $spacing-lg;
|
||||
padding-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
// Detail Grid - extends common
|
||||
@@ -876,26 +875,6 @@
|
||||
// 모바일에서는 2개 버튼만 표시되는 경우 (이전, 수정)
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 모바일: 앱 수정 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
|
||||
&.app-modify-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PC 환경 안내 문구 - 모바일에서만 노출 (앱 수정)
|
||||
.app-modify-pc-only-message {
|
||||
display: none;
|
||||
margin: 0;
|
||||
padding: 0 20px 24px;
|
||||
color: $text-gray;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
|
||||
@media (max-width: $breakpoint-sm) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,22 +194,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Expected Completion Date (pending requests only)
|
||||
.app-list-expect-date {
|
||||
margin-left: auto;
|
||||
font-family: $font-family-primary;
|
||||
font-size: 14px;
|
||||
font-weight: $font-weight-regular;
|
||||
color: #6e7780;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
|
||||
// Figma 모바일: 12px
|
||||
@include respond-to('sm') {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// App Description
|
||||
.app-list-description {
|
||||
font-family: $font-family-primary;
|
||||
@@ -262,23 +246,4 @@
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// 모바일: 앱 생성 마법사 UI 표현이 어려워 버튼 숨기고 PC 안내 노출
|
||||
@include respond-to('sm') {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
// PC 환경 안내 문구 - 모바일에서만 노출
|
||||
.app-create-pc-only-message {
|
||||
display: none;
|
||||
margin: 0;
|
||||
color: $text-gray;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
|
||||
@include respond-to('sm') {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||