Compare commits
8 Commits
dfb37c18b9
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d6181210 | |||
| bac9a54dee | |||
| 603cba65a7 | |||
| 889d77b990 | |||
| bf2dad6ba7 | |||
| 3a1b0cb9c1 | |||
| a13279a777 | |||
| 5299352235 |
@@ -15,6 +15,10 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageImpl;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.ui.ModelMap;
|
||||
@@ -79,12 +83,17 @@ public class ApiController {
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String apiList(@ModelAttribute ApiGroupSearch search, Model model) {
|
||||
public String apiList(@ModelAttribute ApiGroupSearch search, @PageableDefault Pageable pageable, Model model) {
|
||||
Map<String, Object> searchResult = apiSearchFacade.searchApis(search);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<ApiSpecInfoDto> allApis = (List<ApiSpecInfoDto>) searchResult.get("apis");
|
||||
Page<ApiSpecInfoDto> apiPage = slicePage(allApis, pageable);
|
||||
|
||||
model.addAttribute("search", search);
|
||||
model.addAttribute("services", searchResult.get("services"));
|
||||
model.addAttribute("apis", searchResult.get("apis"));
|
||||
model.addAttribute("apis", apiPage.getContent());
|
||||
model.addAttribute("page", apiPage);
|
||||
model.addAttribute("totalApiCount", searchResult.get("totalApiCount"));
|
||||
model.addAttribute("selectedApiCount", searchResult.get("selectedApiCount"));
|
||||
model.addAttribute("selected", search.getGroupIds().size() > 0 ? search.getGroupIds().get(0) : "-1");
|
||||
@@ -94,6 +103,19 @@ public class ApiController {
|
||||
return "apps/apis/mainApiList";
|
||||
}
|
||||
|
||||
/**
|
||||
* apiSearchFacade.searchApis() 는 다른 소비처(API Status 필터 목록 등)와 계약을 공유하므로
|
||||
* 항상 전체 List 를 돌려준다. 목록 화면 렌더링에서만 결과를 잘라 Page 로 감싼다.
|
||||
*/
|
||||
private Page<ApiSpecInfoDto> slicePage(List<ApiSpecInfoDto> apis, Pageable pageable) {
|
||||
int total = apis == null ? 0 : apis.size();
|
||||
int fromIndex = Math.min(pageable.getPageNumber() * pageable.getPageSize(), total);
|
||||
int toIndex = Math.min(fromIndex + pageable.getPageSize(), total);
|
||||
List<ApiSpecInfoDto> content = total == 0 ? new ArrayList<>() : apis.subList(fromIndex, toIndex);
|
||||
|
||||
return new PageImpl<>(content, pageable, total);
|
||||
}
|
||||
|
||||
@GetMapping("/testbed/api")
|
||||
public String testbedByApi(@RequestParam(value = "id", required = false) String id, Model model) {
|
||||
// 테스트베드는 로그인한 사용자만 접근 가능. 미인증 시 사유와 함께 로그인 페이지로 유도.
|
||||
|
||||
@@ -46,4 +46,6 @@ public class ApiSpecInfoDto {
|
||||
private String displayRoleCode;
|
||||
|
||||
private String apiGroupName;
|
||||
|
||||
private String apiGroupId;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,9 @@ public class ApiSearchFacadeImpl implements ApiSearchFacade {
|
||||
filteredApis.forEach(api -> {
|
||||
ApiServiceDTO service = apiData.getServicesByApiId().get(api.getApiId());
|
||||
if (service != null) {
|
||||
api.setMainIcon(service.getMainIcon());
|
||||
// mainIcon(CLOB, base64)은 카드 수만큼 복제하지 않는다. 렌더링은
|
||||
// apiGroupId 기준 /api-services/{id}/icon 스트리밍 엔드포인트를 사용한다.
|
||||
api.setApiGroupId(service.getId());
|
||||
api.setApiGroupName(service.getGroupName());
|
||||
}
|
||||
});
|
||||
|
||||
+37
@@ -5,8 +5,17 @@ import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceDTO;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiServiceTabInfo;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -20,6 +29,10 @@ import org.springframework.web.servlet.ModelAndView;
|
||||
@RequiredArgsConstructor
|
||||
public class ApiServiceController {
|
||||
|
||||
// ApiGroup.mainIcon 은 "data:image/png;base64,...." 형태의 Data URL 문자열 그대로 저장돼 있다.
|
||||
private static final Pattern DATA_URL_PATTERN = Pattern.compile("^data:(image/[a-zA-Z0-9+.-]+);base64,(.+)$", Pattern.DOTALL);
|
||||
private static final String DEFAULT_ICON_PATH = "/img/api_icon_default.png";
|
||||
|
||||
private final ApiServiceService apiServiceService;
|
||||
|
||||
|
||||
@@ -52,4 +65,28 @@ public class ApiServiceController {
|
||||
|
||||
return "apps/apiservice/apiServiceDetail";
|
||||
}
|
||||
|
||||
/**
|
||||
* API 그룹 아이콘 스트리밍. mainIcon(CLOB, Data URL 문자열)을 그대로 HTML에 인라인하면
|
||||
* 카드 개수만큼 반복 전송돼 응답 용량이 폭증하므로, 그룹 id 당 1회만 내려주고
|
||||
* 브라우저 캐시로 재사용시킨다.
|
||||
*/
|
||||
@GetMapping("/{id}/icon")
|
||||
public ResponseEntity<byte[]> icon(@PathVariable String id) {
|
||||
String mainIcon = apiServiceService.getMainIcon(id);
|
||||
Matcher matcher = mainIcon == null ? null : DATA_URL_PATTERN.matcher(mainIcon);
|
||||
|
||||
if (matcher == null || !matcher.matches()) {
|
||||
return ResponseEntity.status(HttpStatus.FOUND)
|
||||
.header(HttpHeaders.LOCATION, DEFAULT_ICON_PATH)
|
||||
.build();
|
||||
}
|
||||
|
||||
byte[] imageBytes = Base64.getDecoder().decode(matcher.group(2));
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.parseMediaType(matcher.group(1)))
|
||||
.cacheControl(CacheControl.maxAge(1, TimeUnit.DAYS).cachePublic())
|
||||
.body(imageBytes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,5 +120,14 @@ public class ApiServiceService {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹 아이콘(mainIcon)만 가볍게 조회. {@link #getApiGroupById} 는 apiGroupApiList 까지
|
||||
* 함께 로드해 무거우므로, 아이콘 스트리밍 엔드포인트 전용으로 CLOB 값만 꺼낸다.
|
||||
*/
|
||||
public String getMainIcon(String id) {
|
||||
return apiServiceRepository.findById(id)
|
||||
.map(ApiGroup::getMainIcon)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+5
@@ -6,6 +6,7 @@ import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Repository
|
||||
@EMSDataSource
|
||||
@@ -16,4 +17,8 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
|
||||
* createdBy 는 PersonalDataEncryptConverter 로 결정적 암호화되므로 평문 사용자 id 로 등가 조회가 가능하다.
|
||||
*/
|
||||
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
|
||||
|
||||
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
|
||||
@Transactional
|
||||
long deleteByCreatedBy(String createdBy);
|
||||
}
|
||||
|
||||
+4
@@ -7,10 +7,14 @@ import java.util.Optional;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Repository
|
||||
@EMSDataSource
|
||||
public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSpecificationExecutor<Inquiry> {
|
||||
|
||||
Optional<Inquiry> findByInquirerAndId(PortalUser inquirer, String id);
|
||||
|
||||
@Transactional
|
||||
long deleteByInquirer_Id(String inquirerId);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,18 @@ public class GlobalControllerAdvice {
|
||||
return clientGuardService.isDevtoolsGuardEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 상단 헤더 좌측 노출용 활성 프로파일 배지. prod 프로파일이면 노출하지 않는다(null).
|
||||
*/
|
||||
@ModelAttribute("activeProfileBadge")
|
||||
public String activeProfileBadge() {
|
||||
if (environment.acceptsProfiles(Profiles.of("prod"))) {
|
||||
return null;
|
||||
}
|
||||
String[] activeProfiles = environment.getActiveProfiles();
|
||||
return activeProfiles.length == 0 ? "default" : String.join(", ", activeProfiles);
|
||||
}
|
||||
|
||||
/**
|
||||
* 푸터 고객센터 연락처. PortalProperty(Portal/customer.center.contact)에서 조회.
|
||||
* 전화번호가 아닐 수도 있으므로 값 그대로 출력하되 템플릿에서 th:text(HTML escape)로 렌더한다.
|
||||
|
||||
@@ -113,7 +113,10 @@ public class PortalConfigSecurity {
|
||||
// /internal/menu 는 브라우저 세션이 없는 서버간 호출(admin → portal)이라 CSRF 토큰을 실을 수 없다.
|
||||
// 대신 InternalApiTokenService 의 공유 토큰 헤더 + 허용 IP 목록으로 통제한다.
|
||||
// (커스텀 헤더는 cross-site form POST 로 위조할 수 없어 CSRF 경로가 차단된다)
|
||||
.ignoringRequestMatchers(new AntPathRequestMatcher("/internal/menu/**"))
|
||||
// /internal/test-cleanup 도 동일 이유(Playwright 등 세션 없는 호출) + 동일 통제 방식.
|
||||
.ignoringRequestMatchers(
|
||||
new AntPathRequestMatcher("/internal/menu/**"),
|
||||
new AntPathRequestMatcher("/internal/test-cleanup/**"))
|
||||
)
|
||||
// 로그인 페이지에 오래 머물러 세션(=CSRF 토큰 저장소)이 타임아웃되면
|
||||
// 로그인 제출 시 CsrfFilter가 AnonymousAuthenticationFilter보다 먼저 예외를 던져
|
||||
|
||||
+4
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.qna.entity.InquiryComment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
@@ -17,4 +18,7 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
|
||||
+ " group by c.inquiry.id")
|
||||
List<Object[]> countActiveGroupByInquiry(@Param("inquiryIds") Collection<String> inquiryIds,
|
||||
@Param("delYn") String delYn);
|
||||
|
||||
@Transactional
|
||||
long deleteByInquiry_Inquirer_Id(String inquirerId);
|
||||
}
|
||||
|
||||
+190
@@ -0,0 +1,190 @@
|
||||
package com.eactive.apim.portal.djb.testcleanup.controller;
|
||||
|
||||
import com.eactive.apim.portal.common.internal.InternalApiTokenService;
|
||||
import com.eactive.apim.portal.common.util.IpAddressMatcher;
|
||||
import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService;
|
||||
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult;
|
||||
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService;
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Playwright E2E 테스트 정리용 내부 API — local(개인 dev 프로필, 항상 {@code dev} 동반 활성화)과
|
||||
* 배포된 dev 서버 양쪽에서만 존재한다({@code @Profile("dev")}, stage/prod 는 빈 자체가 없어 404).
|
||||
*
|
||||
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
|
||||
* <ol>
|
||||
* <li><b>공유 토큰 헤더</b> — {@link InternalApiTokenService}(menu.reload 와 동일 토큰 재사용).</li>
|
||||
* <li><b>허용 IP 목록</b> — PTL_PROPERTY {@code Portal / testcleanup.internal.allow-ips}(기본 loopback).
|
||||
* menu 와 별도 프로퍼티라 Playwright 실행 호스트만 좁게 허용할 수 있다.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>파괴적 작업 안전장치: {@code org}/{@code user} 삭제는 구체적 식별자(compRegNo/email)가 필수이며
|
||||
* 와일드카드/전체삭제 파라미터는 없다. {@code orphans} 스윕은 이미 사라진 org/user 를 참조하는 행만
|
||||
* 대상이라 살아있는 테스트 데이터를 지울 위험이 없다.</p>
|
||||
*
|
||||
* <pre>curl -X POST -H 'X-Internal-Token: <PTL_PROPERTY Portal/internal.api.token>' \
|
||||
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
|
||||
*/
|
||||
@Slf4j
|
||||
@Profile("dev")
|
||||
@RestController
|
||||
@RequestMapping("/internal/test-cleanup")
|
||||
@RequiredArgsConstructor
|
||||
public class TestCleanupInternalController {
|
||||
|
||||
static final String PROP_GROUP = "Portal";
|
||||
static final String PROP_ALLOW_IPS = "testcleanup.internal.allow-ips";
|
||||
static final String DEFAULT_ALLOW_IPS = "127.0.0.1,::1";
|
||||
static final String PROP_ALLOW_IPS_DESCRIPTION =
|
||||
"테스트 정리 내부 API(법인/계정/고아데이터 삭제) 허용 IP 목록. 콤마(,)/세미콜론(;)/줄바꿈 구분, "
|
||||
+ "정확일치·IPv4 CIDR(172.30.1.0/24)·와일드카드(172.30.*.*) 지원";
|
||||
|
||||
private final TestCleanupService testCleanupService;
|
||||
private final OrphanCleanupService orphanCleanupService;
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
private final InternalApiTokenService internalApiTokenService;
|
||||
|
||||
@PostMapping("/org")
|
||||
public ResponseEntity<Map<String, Object>> deleteOrg(
|
||||
@RequestParam String compRegNo, HttpServletRequest request) {
|
||||
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||
if (guardFailure != null) {
|
||||
return guardFailure;
|
||||
}
|
||||
if (StringUtils.isBlank(compRegNo)) {
|
||||
return badRequest("compRegNo 는 필수입니다.");
|
||||
}
|
||||
|
||||
TestCleanupResult result = testCleanupService.deleteOrgCascade(compRegNo);
|
||||
log.info("테스트 정리(org) 실행 - compRegNo: {}, from: {}", compRegNo, request.getRemoteAddr());
|
||||
|
||||
Map<String, Object> body = baseBody();
|
||||
body.put("compRegNo", compRegNo);
|
||||
body.put("orgId", result.getTargetId());
|
||||
body.put("orgFound", result.isFound());
|
||||
body.put("deletedCounts", result.getDeletedCounts());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/user")
|
||||
public ResponseEntity<Map<String, Object>> deleteUser(
|
||||
@RequestParam String email, HttpServletRequest request) {
|
||||
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||
if (guardFailure != null) {
|
||||
return guardFailure;
|
||||
}
|
||||
if (StringUtils.isBlank(email)) {
|
||||
return badRequest("email 은 필수입니다.");
|
||||
}
|
||||
|
||||
TestCleanupResult result = testCleanupService.deleteUserCascade(email);
|
||||
log.info("테스트 정리(user) 실행 - email: {}, from: {}", email, request.getRemoteAddr());
|
||||
|
||||
Map<String, Object> body = baseBody();
|
||||
body.put("email", email);
|
||||
body.put("userId", result.getTargetId());
|
||||
body.put("userFound", result.isFound());
|
||||
body.put("deletedCounts", result.getDeletedCounts());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
@PostMapping("/orphans")
|
||||
public ResponseEntity<Map<String, Object>> cleanOrphans(HttpServletRequest request) {
|
||||
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
|
||||
if (guardFailure != null) {
|
||||
return guardFailure;
|
||||
}
|
||||
|
||||
TestCleanupResult result = orphanCleanupService.sweep();
|
||||
log.info("테스트 정리(orphans) 실행 - from: {}", request.getRemoteAddr());
|
||||
|
||||
Map<String, Object> body = baseBody();
|
||||
body.put("deletedCounts", result.getDeletedCounts());
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> checkGuards(HttpServletRequest request) {
|
||||
if (!isAllowedIp(request)) {
|
||||
return denied(HttpStatus.FORBIDDEN,
|
||||
"허용되지 않은 접근입니다. (" + PROP_GROUP + "/" + PROP_ALLOW_IPS + " 확인)");
|
||||
}
|
||||
if (!hasValidToken(request)) {
|
||||
return denied(HttpStatus.UNAUTHORIZED,
|
||||
"내부 API 토큰이 유효하지 않습니다. (" + InternalApiTokenService.PROP_GROUP + "/"
|
||||
+ InternalApiTokenService.PROP_TOKEN + " 확인)");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<String, Object> baseBody() {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("result", "OK");
|
||||
body.put("processedAt", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
return body;
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> badRequest(String message) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("result", "BAD_REQUEST");
|
||||
body.put("message", message);
|
||||
return ResponseEntity.badRequest().body(body);
|
||||
}
|
||||
|
||||
private ResponseEntity<Map<String, Object>> denied(HttpStatus status, String message) {
|
||||
Map<String, Object> denied = new LinkedHashMap<>();
|
||||
denied.put("result", "DENIED");
|
||||
denied.put("message", message);
|
||||
return ResponseEntity.status(status).body(denied);
|
||||
}
|
||||
|
||||
/**
|
||||
* 공유 토큰 헤더 검사. 헤더명은 PTL_PROPERTY 로 바뀔 수 있으므로 매 요청 조회한다(호출 빈도가 낮다).
|
||||
*/
|
||||
private boolean hasValidToken(HttpServletRequest request) {
|
||||
String headerName = internalApiTokenService.ensureHeaderName();
|
||||
if (!internalApiTokenService.matches(request.getHeader(headerName))) {
|
||||
log.warn("테스트 정리 내부 API 차단 - 토큰 불일치, remote: {}, header: {}", request.getRemoteAddr(), headerName);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 허용 IP 검사. 프록시 경유(X-Forwarded-For 존재) 요청은 IP 신뢰 불가로 거부한다.
|
||||
*/
|
||||
private boolean isAllowedIp(HttpServletRequest request) {
|
||||
String remote = IpAddressMatcher.canonicalize(request.getRemoteAddr());
|
||||
boolean viaProxy = request.getHeader("X-Forwarded-For") != null;
|
||||
|
||||
if (viaProxy || !IpAddressMatcher.matches(resolveAllowIps(), remote)) {
|
||||
log.warn("테스트 정리 내부 API 차단 - remote: {}, viaProxy: {}", remote, viaProxy);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private String resolveAllowIps() {
|
||||
try {
|
||||
return portalPropertyService.getOrCreateProperty(PROP_GROUP, PROP_ALLOW_IPS,
|
||||
DEFAULT_ALLOW_IPS, PROP_ALLOW_IPS_DESCRIPTION);
|
||||
} catch (Exception e) {
|
||||
log.warn("허용 IP 목록 조회 실패 - 기본값({}) 사용", DEFAULT_ALLOW_IPS, e);
|
||||
return DEFAULT_ALLOW_IPS;
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
|
||||
* 13개 테이블에서 스윕 삭제한다. local(dev 동반 활성화)·dev 서버에서만 동작한다({@link TestCleanupService}
|
||||
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Profile("dev")
|
||||
@Service
|
||||
@Transactional
|
||||
public class OrphanCleanupService {
|
||||
|
||||
private final Environment environment;
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
public OrphanCleanupService(Environment environment) {
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
public TestCleanupResult sweep() {
|
||||
assertNonProdProfile();
|
||||
TestCleanupResult result = TestCleanupResult.found(null);
|
||||
|
||||
// 자식 -> 부모 순서 (FK 제약은 없으나 논리적 정합성 유지 목적)
|
||||
result.put("PTL_INQUIRY_COMMENT", exec(
|
||||
"DELETE FROM PTL_INQUIRY_COMMENT WHERE INQUIRY_ID IN "
|
||||
+ "(SELECT ID FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER))"));
|
||||
result.put("PTL_INQUIRY", exec(
|
||||
"DELETE FROM PTL_INQUIRY WHERE INQUIRER_ID IS NOT NULL AND INQUIRER_ID NOT IN (SELECT ID FROM PTL_USER)"));
|
||||
result.put("PTL_PARTNERSHIP_APPLICATION", exec(
|
||||
"DELETE FROM PTL_PARTNERSHIP_APPLICATION WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)"));
|
||||
result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", exec(
|
||||
"DELETE FROM PTL_USER_PRIVACY_POLICY_AGREEMENT WHERE CREATED_BY IS NOT NULL AND CREATED_BY NOT IN (SELECT ID FROM PTL_USER)"));
|
||||
result.put("PTL_USER_PASSWORD_HISTORY", exec(
|
||||
"DELETE FROM PTL_USER_PASSWORD_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT ID FROM PTL_USER)"));
|
||||
result.put("PTL_USER_ROLE_HISTORY", exec(
|
||||
"DELETE FROM PTL_USER_ROLE_HISTORY WHERE USER_ID IS NOT NULL AND USER_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)"));
|
||||
result.put("PTL_USER_LOG", exec(
|
||||
"DELETE FROM PTL_USER_LOG WHERE LOGIN_ID IS NOT NULL AND LOGIN_ID NOT IN (SELECT LOGIN_ID FROM PTL_USER)"));
|
||||
result.put("PTL_CREDENTIAL_API", exec(
|
||||
"DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN "
|
||||
+ "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG))"));
|
||||
result.put("PTL_CREDENTIAL", exec(
|
||||
"DELETE FROM PTL_CREDENTIAL WHERE ORGID IS NOT NULL AND ORGID NOT IN (SELECT ID FROM PTL_ORG)"));
|
||||
result.put("PTL_WEBHOOK_REQ_API", exec(
|
||||
"DELETE FROM PTL_WEBHOOK_REQ_API WHERE WEBHOOK_REQ_ID IN "
|
||||
+ "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))"));
|
||||
result.put("PTL_WEBHOOK_REQ_EVENT", exec(
|
||||
"DELETE FROM PTL_WEBHOOK_REQ_EVENT WHERE WEBHOOK_REQ_ID IN "
|
||||
+ "(SELECT ID FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG))"));
|
||||
result.put("PTL_WEBHOOK_REQ", exec(
|
||||
"DELETE FROM PTL_WEBHOOK_REQ WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)"));
|
||||
result.put("PTL_WEBHOOK_SEND_LOG", exec(
|
||||
"DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID IS NOT NULL AND ORG_ID NOT IN (SELECT ID FROM PTL_ORG)"));
|
||||
|
||||
log.info("테스트 정리 - 고아 데이터 스윕 완료: {}", result.getDeletedCounts());
|
||||
return result;
|
||||
}
|
||||
|
||||
private long exec(String sql) {
|
||||
return entityManager.createNativeQuery(sql).executeUpdate();
|
||||
}
|
||||
|
||||
private void assertNonProdProfile() {
|
||||
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
|
||||
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.PersistenceContext;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
|
||||
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
|
||||
*/
|
||||
@Profile("dev")
|
||||
@Repository
|
||||
class TestCleanupNativeQueries {
|
||||
|
||||
@PersistenceContext
|
||||
private EntityManager entityManager;
|
||||
|
||||
int deleteCredentialApiByOrgId(String orgId) {
|
||||
return entityManager.createNativeQuery(
|
||||
"DELETE FROM PTL_CREDENTIAL_API WHERE CLIENT_ID IN "
|
||||
+ "(SELECT CLIENTID FROM PTL_CREDENTIAL WHERE ORGID = :orgId)")
|
||||
.setParameter("orgId", orgId)
|
||||
.executeUpdate();
|
||||
}
|
||||
|
||||
int deleteWebhookSendLogByOrgId(String orgId) {
|
||||
return entityManager.createNativeQuery(
|
||||
"DELETE FROM PTL_WEBHOOK_SEND_LOG WHERE ORG_ID = :orgId")
|
||||
.setParameter("orgId", orgId)
|
||||
.executeUpdate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 테스트 정리 API 응답용 결과 누적기. 대상 존재 여부 + 테이블별 삭제 건수를 담는다.
|
||||
*/
|
||||
public class TestCleanupResult {
|
||||
|
||||
private boolean found;
|
||||
private String targetId;
|
||||
private final Map<String, Long> deletedCounts = new LinkedHashMap<>();
|
||||
|
||||
public static TestCleanupResult notFound() {
|
||||
return new TestCleanupResult();
|
||||
}
|
||||
|
||||
public static TestCleanupResult found(String targetId) {
|
||||
TestCleanupResult result = new TestCleanupResult();
|
||||
result.found = true;
|
||||
result.targetId = targetId;
|
||||
return result;
|
||||
}
|
||||
|
||||
public void put(String table, long count) {
|
||||
deletedCounts.merge(table, count, Long::sum);
|
||||
}
|
||||
|
||||
public void merge(TestCleanupResult other) {
|
||||
other.deletedCounts.forEach(this::put);
|
||||
}
|
||||
|
||||
public boolean isFound() {
|
||||
return found;
|
||||
}
|
||||
|
||||
public String getTargetId() {
|
||||
return targetId;
|
||||
}
|
||||
|
||||
public Map<String, Long> getDeletedCounts() {
|
||||
return deletedCounts;
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
|
||||
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Profile;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과
|
||||
* 그에 딸린 데이터를 하드 삭제한다. local(dev 동반 활성화)·dev 서버에서만 존재하는 빈이며,
|
||||
* stage/prod 는 {@code @Profile("dev")} 로 빈 자체가 등록되지 않는다.
|
||||
*
|
||||
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Profile("dev")
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional
|
||||
public class TestCleanupService {
|
||||
|
||||
private final Environment environment;
|
||||
private final PortalOrgRepository portalOrgRepository;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final InquiryRepository inquiryRepository;
|
||||
private final InquiryCommentRepository inquiryCommentRepository;
|
||||
private final PartnershipApplicationRepository partnershipApplicationRepository;
|
||||
private final UserRoleHistoryRepository userRoleHistoryRepository;
|
||||
private final PortalUserPrivacyAgreementRepository portalUserPrivacyAgreementRepository;
|
||||
private final UserPasswordHistoryRepository userPasswordHistoryRepository;
|
||||
private final UserLogRepository userLogRepository;
|
||||
private final CredentialRepository credentialRepository;
|
||||
private final WebhookRequestRepository webhookRequestRepository;
|
||||
private final WebhookRequestApiRepository webhookRequestApiRepository;
|
||||
private final WebhookRequestEventRepository webhookRequestEventRepository;
|
||||
private final WebhookService webhookService;
|
||||
private final TestCleanupNativeQueries nativeQueries;
|
||||
|
||||
/**
|
||||
* 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다.
|
||||
*/
|
||||
public TestCleanupResult deleteOrgCascade(String compRegNo) {
|
||||
assertNonProdProfile();
|
||||
|
||||
Optional<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo);
|
||||
if (!orgOpt.isPresent()) {
|
||||
return TestCleanupResult.notFound();
|
||||
}
|
||||
PortalOrg org = orgOpt.get();
|
||||
String orgId = org.getId();
|
||||
TestCleanupResult result = TestCleanupResult.found(orgId);
|
||||
|
||||
List<PortalUser> users = portalUserRepository.findAllByPortalOrg_Id(orgId);
|
||||
for (PortalUser user : users) {
|
||||
result.merge(deleteUserCascadeInternal(user));
|
||||
}
|
||||
|
||||
result.put("PTL_CREDENTIAL_API", nativeQueries.deleteCredentialApiByOrgId(orgId));
|
||||
result.put("PTL_CREDENTIAL", credentialRepository.deleteByOrgid(orgId));
|
||||
|
||||
Optional<WebhookRequest> webhook = webhookRequestRepository.findByOrgId(orgId);
|
||||
if (webhook.isPresent()) {
|
||||
Long webhookReqId = webhook.get().getId();
|
||||
result.put("PTL_WEBHOOK_REQ_API", webhookRequestApiRepository.findByWebhookReqId(webhookReqId).size());
|
||||
result.put("PTL_WEBHOOK_REQ_EVENT", webhookRequestEventRepository.findByWebhookReqId(webhookReqId).size());
|
||||
webhookService.delete(webhookReqId, orgId);
|
||||
result.put("PTL_WEBHOOK_REQ", 1L);
|
||||
} else {
|
||||
result.put("PTL_WEBHOOK_REQ_API", 0L);
|
||||
result.put("PTL_WEBHOOK_REQ_EVENT", 0L);
|
||||
result.put("PTL_WEBHOOK_REQ", 0L);
|
||||
}
|
||||
result.put("PTL_WEBHOOK_SEND_LOG", nativeQueries.deleteWebhookSendLogByOrgId(orgId));
|
||||
|
||||
portalOrgRepository.delete(org);
|
||||
result.put("PTL_ORG", 1L);
|
||||
|
||||
log.info("테스트 정리 - 법인 완전삭제 완료: compRegNo={}, orgId={}, userCount={}", compRegNo, orgId, users.size());
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이메일로 계정을 찾아 단독 하드 삭제한다(org 소속 여부와 무관 — org 소유 CREDENTIAL/WEBHOOK 은 건드리지 않는다).
|
||||
*/
|
||||
public TestCleanupResult deleteUserCascade(String email) {
|
||||
assertNonProdProfile();
|
||||
|
||||
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
|
||||
if (!userOpt.isPresent()) {
|
||||
return TestCleanupResult.notFound();
|
||||
}
|
||||
PortalUser user = userOpt.get();
|
||||
TestCleanupResult result = deleteUserCascadeInternal(user);
|
||||
log.info("테스트 정리 - 계정 완전삭제 완료: userId={}", user.getId());
|
||||
return result;
|
||||
}
|
||||
|
||||
private TestCleanupResult deleteUserCascadeInternal(PortalUser user) {
|
||||
TestCleanupResult result = TestCleanupResult.found(user.getId());
|
||||
result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId()));
|
||||
result.put("PTL_INQUIRY", inquiryRepository.deleteByInquirer_Id(user.getId()));
|
||||
result.put("PTL_PARTNERSHIP_APPLICATION", partnershipApplicationRepository.deleteByCreatedBy(user.getId()));
|
||||
// UserRoleHistory.userId 는 loginId 를 저장한다(다른 이력 테이블과 시맨틱이 반대이므로 혼동 주의).
|
||||
result.put("PTL_USER_ROLE_HISTORY", userRoleHistoryRepository.deleteByUserId(user.getLoginId()));
|
||||
result.put("PTL_USER_PRIVACY_POLICY_AGREEMENT", portalUserPrivacyAgreementRepository.deleteByCreatedBy(user.getId()));
|
||||
// UserPasswordHistory.userId 는 PortalUser.id 를 저장한다.
|
||||
result.put("PTL_USER_PASSWORD_HISTORY", userPasswordHistoryRepository.deleteByUserId(user.getId()));
|
||||
result.put("PTL_USER_LOG", userLogRepository.deleteByLoginId(user.getLoginId()));
|
||||
portalUserRepository.delete(user);
|
||||
result.put("PTL_USER", 1L);
|
||||
return result;
|
||||
}
|
||||
|
||||
private void assertNonProdProfile() {
|
||||
if (environment.acceptsProfiles(Profiles.of("stage", "prod"))) {
|
||||
throw new IllegalStateException("stage/prod 환경에서는 테스트 정리 API를 수행할 수 없습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,11 @@ server:
|
||||
# RequestHeader set X-Forwarded-Proto "https" / X-Forwarded-Port "1443" (mod_wl_ohs 는 미전송)
|
||||
forward-headers-strategy: native
|
||||
|
||||
compression:
|
||||
enabled: true
|
||||
mime-types: text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json
|
||||
min-response-size: 1024
|
||||
|
||||
|
||||
spring:
|
||||
config:
|
||||
|
||||
@@ -19,6 +19,21 @@
|
||||
<property name="__consoleLevel.FALSE" value="OFF"/>
|
||||
<property name="CONSOLE_EFFECTIVE_LEVEL" value="${__consoleLevel.${CONSOLE_LOG_ENABLED}}"/>
|
||||
|
||||
<!-- 파일 로그(ROLLING/ERROR_FILE 등 root 하위 appender) 레벨 JVM 파라미터 제어 -->
|
||||
<!-- 사용: -DFILE_LOG_LEVEL=ERROR (전체 미남김: -DFILE_LOG_LEVEL=OFF) -->
|
||||
<!-- 기본값: dev 프로파일 DEBUG, 그 외 INFO (기존 동작 유지) -->
|
||||
<property name="FILE_LOG_LEVEL" value="${FILE_LOG_LEVEL:-INFO}"/>
|
||||
<property name="FILE_LOG_LEVEL_DEV" value="${FILE_LOG_LEVEL:-DEBUG}"/>
|
||||
|
||||
<!-- API 테스터 감사 로그(eapim.portal.apitester.audit) on/off JVM 파라미터 제어 -->
|
||||
<!-- 사용: -DAPI_TESTER_AUDIT_LOG_ENABLED=false / 기본값: true(켜짐, INFO) -->
|
||||
<property name="API_TESTER_AUDIT_LOG_ENABLED" value="${API_TESTER_AUDIT_LOG_ENABLED:-true}"/>
|
||||
<property name="__apiTesterAuditLevel.true" value="INFO"/>
|
||||
<property name="__apiTesterAuditLevel.TRUE" value="INFO"/>
|
||||
<property name="__apiTesterAuditLevel.false" value="OFF"/>
|
||||
<property name="__apiTesterAuditLevel.FALSE" value="OFF"/>
|
||||
<property name="API_TESTER_AUDIT_EFFECTIVE_LEVEL" value="${__apiTesterAuditLevel.${API_TESTER_AUDIT_LOG_ENABLED}}"/>
|
||||
|
||||
|
||||
<appender name="HIBERNATE_APPENDER" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<file>${LOG_PATH}/hibernate.log</file>
|
||||
@@ -95,23 +110,23 @@
|
||||
</appender>
|
||||
|
||||
|
||||
<logger name="eapim.portal.session" level="INFO" additivity="false">
|
||||
<logger name="eapim.portal.session" level="${FILE_LOG_LEVEL}" additivity="false">
|
||||
<appender-ref ref="HTTP_SESSION" />
|
||||
</logger>
|
||||
|
||||
<logger name="eapim.portal.apitester.audit" level="INFO" additivity="false">
|
||||
<logger name="eapim.portal.apitester.audit" level="${API_TESTER_AUDIT_EFFECTIVE_LEVEL}" additivity="false">
|
||||
<appender-ref ref="API_TESTER_AUDIT" />
|
||||
</logger>
|
||||
|
||||
|
||||
<root level="INFO">
|
||||
<root level="${FILE_LOG_LEVEL}">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
</root>
|
||||
|
||||
<springProfile name="dev">
|
||||
<root level="DEBUG">
|
||||
<root level="${FILE_LOG_LEVEL_DEV}">
|
||||
<appender-ref ref="ROLLING"/>
|
||||
<appender-ref ref="CONSOLE"/>
|
||||
<appender-ref ref="ERROR_FILE"/>
|
||||
|
||||
@@ -821,6 +821,50 @@ hr {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.env-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--accent-orange);
|
||||
color: var(--white);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.env-badge {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.env-strip {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.env-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 16px;
|
||||
background: var(--accent-orange);
|
||||
color: var(--white);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
z-index: 1100;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1192,7 +1236,7 @@ hr {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
|
||||
background: rgb(0, 65.7, 162);
|
||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
}
|
||||
.mobile-drawer .drawer-welcome.authenticated {
|
||||
flex-direction: row;
|
||||
@@ -2503,7 +2547,7 @@ hr {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.btn-danger {
|
||||
@@ -2511,7 +2555,7 @@ hr {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
.btn-ghost {
|
||||
@@ -2777,7 +2821,7 @@ hr {
|
||||
.action-btn-delete:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
background: rgb(255, 88.9, 88.9);
|
||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
||||
}
|
||||
.action-btn-delete:active {
|
||||
transform: translateY(0);
|
||||
@@ -2895,7 +2939,7 @@ hr {
|
||||
background: #a4d6ea;
|
||||
}
|
||||
.btn-input-action.btn-change:hover {
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
}
|
||||
@@ -2970,7 +3014,7 @@ hr {
|
||||
border: none;
|
||||
}
|
||||
.btn-action-primary:hover {
|
||||
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
|
||||
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
|
||||
transform: translateY(-2px);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -3020,7 +3064,7 @@ hr {
|
||||
}
|
||||
.status-badge.status-processing {
|
||||
background: rgba(255, 217, 61, 0.1);
|
||||
color: rgb(221.2, 177.8721649485, 0);
|
||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
||||
}
|
||||
.status-badge.status-failed {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
@@ -3060,7 +3104,7 @@ hr {
|
||||
}
|
||||
.status-badge-header.status-processing {
|
||||
background: rgba(255, 217, 61, 0.1);
|
||||
color: rgb(221.2, 177.8721649485, 0);
|
||||
color: rgb(86.7450980392%, 69.7537901759%, 0%);
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
@@ -4248,7 +4292,7 @@ select.form-control {
|
||||
.file-upload-wrapper .file-remove-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
|
||||
background: rgb(255, 88.9, 88.9);
|
||||
background: rgb(100%, 34.862745098%, 34.862745098%);
|
||||
}
|
||||
.file-upload-wrapper .file-remove-btn:active {
|
||||
transform: translateY(0);
|
||||
@@ -4569,7 +4613,7 @@ select.form-control {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.form-actions--with-withdrawal .withdrawal-link:hover {
|
||||
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
|
||||
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
|
||||
}
|
||||
.form-actions--with-withdrawal .withdrawal-link img {
|
||||
width: 22px;
|
||||
@@ -4724,7 +4768,7 @@ select.form-control {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.notice-content-box a:hover {
|
||||
color: rgb(0, 65.7, 162);
|
||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
}
|
||||
|
||||
.form-row--content .form-label-wrapper {
|
||||
@@ -5660,7 +5704,7 @@ select.form-control {
|
||||
font-size: 16px;
|
||||
}
|
||||
.drawer-logout-btn:hover {
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
|
||||
}
|
||||
@@ -6373,7 +6417,7 @@ select.form-control {
|
||||
color: #64748b;
|
||||
}
|
||||
.list-table-btn--default:hover {
|
||||
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
|
||||
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
|
||||
}
|
||||
.list-table-btn--primary {
|
||||
background-color: #ecf0fa;
|
||||
@@ -6381,7 +6425,7 @@ select.form-control {
|
||||
color: #2a69de;
|
||||
}
|
||||
.list-table-btn--primary:hover {
|
||||
background-color: rgb(216.7625, 224.8125, 244.9375);
|
||||
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
|
||||
}
|
||||
.list-table-btn--secondary {
|
||||
background-color: #f5f5f4;
|
||||
@@ -6389,7 +6433,7 @@ select.form-control {
|
||||
color: #64748b;
|
||||
}
|
||||
.list-table-btn--secondary:hover {
|
||||
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
|
||||
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
|
||||
}
|
||||
.list-table-btn--danger {
|
||||
background-color: #fbe7e9;
|
||||
@@ -6397,7 +6441,7 @@ select.form-control {
|
||||
color: #bb1026;
|
||||
}
|
||||
.list-table-btn--danger:hover {
|
||||
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
|
||||
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
@@ -7047,7 +7091,7 @@ select.form-control {
|
||||
.alert.alert-error {
|
||||
background: rgba(255, 107, 107, 0.1);
|
||||
border: 1px solid rgba(255, 107, 107, 0.3);
|
||||
color: rgb(255, 70.8, 70.8);
|
||||
color: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
align-items: center;
|
||||
}
|
||||
.alert.alert-error svg {
|
||||
@@ -7061,7 +7105,7 @@ select.form-control {
|
||||
.alert.alert-success {
|
||||
background: rgba(107, 207, 127, 0.1);
|
||||
border: 1px solid rgba(107, 207, 127, 0.3);
|
||||
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
|
||||
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
|
||||
}
|
||||
.alert.alert-info {
|
||||
background: rgba(0, 73, 180, 0.1);
|
||||
@@ -11432,10 +11476,10 @@ body.index-page-body {
|
||||
line-height: 20px;
|
||||
}
|
||||
.login-button:hover {
|
||||
background: rgb(25.65, 70.3, 173.85);
|
||||
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
|
||||
}
|
||||
.login-button:active {
|
||||
background: rgb(24.3, 66.6, 164.7);
|
||||
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
|
||||
}
|
||||
.login-button:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -11478,10 +11522,10 @@ body.index-page-body {
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
.login-links-container .link-btn:hover {
|
||||
background: rgb(220.61, 227.85, 245.95);
|
||||
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
|
||||
}
|
||||
.login-links-container .link-btn:active {
|
||||
background: rgb(205.22, 215.7, 241.9);
|
||||
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
|
||||
}
|
||||
|
||||
.login-alert {
|
||||
@@ -11990,12 +12034,12 @@ body.index-page-body {
|
||||
}
|
||||
.auth-request-button:hover,
|
||||
.auth-verify-button:hover {
|
||||
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
|
||||
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
|
||||
transform: none !important;
|
||||
}
|
||||
.auth-request-button:active,
|
||||
.auth-verify-button:active {
|
||||
background: rgb(21.411588785, 146.3125233645, 233.148411215);
|
||||
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
|
||||
}
|
||||
.auth-request-button:disabled,
|
||||
.auth-verify-button:disabled {
|
||||
@@ -12044,10 +12088,10 @@ body.index-page-body {
|
||||
background: #f0f2f5;
|
||||
}
|
||||
.account-recovery-card .form-actions .cancel-button:hover {
|
||||
background: rgb(225.45, 229.39, 235.3);
|
||||
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
|
||||
}
|
||||
.account-recovery-card .form-actions .cancel-button:active {
|
||||
background: rgb(210.9, 216.78, 225.6);
|
||||
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button {
|
||||
color: #FFFFFF;
|
||||
@@ -12057,7 +12101,7 @@ body.index-page-body {
|
||||
background: rgb(6, 54, 125);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button:active {
|
||||
background: rgb(0, 65.7, 162);
|
||||
background: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
}
|
||||
.account-recovery-card .form-actions .submit-button:disabled {
|
||||
opacity: 0.6;
|
||||
@@ -12293,7 +12337,7 @@ body.index-page-body {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
.result-info-box .info-text .info-link:hover {
|
||||
color: rgb(0, 65.7, 162);
|
||||
color: rgb(0%, 25.7647058824%, 63.5294117647%);
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.result-info-box .info-text {
|
||||
@@ -17493,7 +17537,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.btn-copy-action:hover {
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.btn-copy-action {
|
||||
@@ -17520,7 +17564,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.btn-view-secret:hover {
|
||||
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
||||
}
|
||||
.btn-view-secret svg {
|
||||
width: 20px;
|
||||
@@ -17705,7 +17749,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.btn-copy-action:hover {
|
||||
background: rgb(131.6625, 199.4303571429, 226.5375);
|
||||
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
|
||||
}
|
||||
.btn-view-secret {
|
||||
width: 100% !important;
|
||||
@@ -17721,7 +17765,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
height: 16px;
|
||||
}
|
||||
.btn-view-secret:hover {
|
||||
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
|
||||
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
|
||||
}
|
||||
#revealedSecretBox {
|
||||
width: 100%;
|
||||
@@ -17994,7 +18038,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.detail-wrap .dt-btn-copy:hover {
|
||||
background: rgb(189.6, 230.0857142857, 255);
|
||||
background: rgb(74.3529411765%, 90.2296918768%, 100%);
|
||||
}
|
||||
.detail-wrap .dt-btn-copy svg {
|
||||
color: #2a69de;
|
||||
@@ -18073,6 +18117,10 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
padding-right: 20px;
|
||||
flex: 1;
|
||||
}
|
||||
.detail-wrap .dt-api-name:hover {
|
||||
color: #2a69de;
|
||||
text-decoration: underline;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.detail-wrap .dt-api-name {
|
||||
font-size: 14px;
|
||||
@@ -18171,7 +18219,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.detail-wrap .dt-btn-gray:hover {
|
||||
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
|
||||
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
|
||||
}
|
||||
.detail-wrap .dt-btn-red {
|
||||
width: 156px;
|
||||
@@ -18189,7 +18237,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
.detail-wrap .dt-btn-red:hover {
|
||||
background: rgb(255, 70.0915337423, 64.24);
|
||||
background: rgb(100%, 27.4868759774%, 25.1921568627%);
|
||||
}
|
||||
.detail-wrap .dt-btn-blue {
|
||||
width: 156px;
|
||||
@@ -19763,7 +19811,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-list:hover {
|
||||
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
||||
}
|
||||
.btn-inquiry-list:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19796,7 +19844,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-edit:hover {
|
||||
background: rgb(0, 69.35, 171);
|
||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
||||
}
|
||||
.btn-inquiry-edit:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19829,7 +19877,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.btn-inquiry-delete:hover {
|
||||
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
|
||||
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
|
||||
}
|
||||
.btn-inquiry-delete:active {
|
||||
transform: scale(0.98);
|
||||
@@ -19901,7 +19949,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.file-upload-inline .btn-remove-file-inline:hover {
|
||||
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
|
||||
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
|
||||
}
|
||||
.file-upload-inline .btn-remove-file-inline svg {
|
||||
width: 12px;
|
||||
@@ -19933,7 +19981,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
}
|
||||
.file-upload-inline .btn-file-attach:hover {
|
||||
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
|
||||
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
|
||||
}
|
||||
.file-upload-inline .btn-file-attach svg {
|
||||
width: 22px;
|
||||
@@ -19993,7 +20041,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: none;
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-secondary:hover {
|
||||
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
|
||||
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-primary {
|
||||
background: #0049b4;
|
||||
@@ -20001,7 +20049,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
border: none;
|
||||
}
|
||||
.inquiry-form-container .form-actions .btn-primary:hover {
|
||||
background: rgb(0, 69.35, 171);
|
||||
background: rgb(0%, 27.1960784314%, 67.0588235294%);
|
||||
}
|
||||
.inquiry-form-container .file-upload-inline .file-input-display {
|
||||
min-height: 50px;
|
||||
@@ -20790,7 +20838,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
cursor: pointer;
|
||||
}
|
||||
.djb-board-write-container .form-actions .btn-submit:hover {
|
||||
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
|
||||
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.djb-board-write-container .form-actions .btn-submit {
|
||||
@@ -21326,7 +21374,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.org-file-remove:hover {
|
||||
background: rgb(255, 70.8, 70.8);
|
||||
background: rgb(100%, 27.7647058824%, 27.7647058824%);
|
||||
}
|
||||
|
||||
.org-file-notice {
|
||||
@@ -22338,7 +22386,7 @@ input[type=checkbox]:checked + .custom-checkbox {
|
||||
}
|
||||
.status-indicator.status-active {
|
||||
background-color: rgba(107, 207, 127, 0.1);
|
||||
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
|
||||
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
|
||||
}
|
||||
.status-indicator.status-active .status-dot {
|
||||
background-color: #6BCF7F;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -156,6 +156,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 활성 Spring 프로파일 표시 (prod 제외)
|
||||
// 데스크톱: 로고 옆 인라인 뱃지 / 모바일·태블릿: 최상단 floating 바(fixed, 레이아웃 미영향)
|
||||
.env-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--accent-orange);
|
||||
color: var(--white);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.6;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.env-strip {
|
||||
display: none;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 16px;
|
||||
background: var(--accent-orange);
|
||||
color: var(--white);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
z-index: 1100;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
// Logo Wrapper
|
||||
.logo-wrapper {
|
||||
display: flex;
|
||||
|
||||
@@ -1198,6 +1198,11 @@
|
||||
padding-right: 20px;
|
||||
flex: 1;
|
||||
|
||||
&:hover {
|
||||
color: #2a69de;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<form id="searchForm" th:action="@{/apis}" method="get">
|
||||
<input type="hidden" name="groupIds"
|
||||
th:value="${search.groupIds != null and !search.groupIds.isEmpty() ? search.groupIds[0] : ''}" />
|
||||
<input type="hidden" name="page" value="1" />
|
||||
<input type="text" class="search-input" name="keyword" th:value="${search.keyword}"
|
||||
placeholder="검색어를 입력하세요.">
|
||||
<button type="submit" class="search-submit-btn" aria-label="검색">
|
||||
@@ -99,13 +100,17 @@
|
||||
|
||||
<!-- API Image (Bottom Right) -->
|
||||
<div class="api-card-image">
|
||||
<img th:if="${api.mainIcon}" th:src="${api.mainIcon}" th:alt="${api.apiGroupName}"
|
||||
onerror="this.style.display='none'">
|
||||
<img th:if="${api.apiGroupId}" th:src="@{/api-services/{id}/icon(id=${api.apiGroupId})}"
|
||||
th:alt="${api.apiGroupName}" onerror="this.style.display='none'">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="pagination" th:if="${apis != null and !apis.isEmpty()}"
|
||||
th:replace="~{fragment/pagination :: pagination(jsFunction='fn_select_page')}"></div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="api-empty-state" th:if="${apis == null or apis.isEmpty()}">
|
||||
<div class="empty-icon">🔍</div>
|
||||
@@ -121,6 +126,14 @@
|
||||
|
||||
<th:block layout:fragment="contentScript">
|
||||
<script th:inline="javascript">
|
||||
// fragment/pagination.html 의 인라인 onclick 에서 호출 (전역 스코프 필요)
|
||||
window.fn_select_page = function (pageNo) {
|
||||
const form = document.getElementById('searchForm');
|
||||
const pageInput = form.querySelector('input[name="page"]');
|
||||
if (pageInput) pageInput.value = pageNo;
|
||||
form.submit();
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// DOM Elements
|
||||
const sidebar = document.getElementById('apiSidebar');
|
||||
@@ -128,6 +141,7 @@
|
||||
const mobileOverlay = document.getElementById('mobileOverlay');
|
||||
const searchForm = document.getElementById('searchForm');
|
||||
const groupIdsInput = searchForm.querySelector('input[name="groupIds"]');
|
||||
const pageInput = searchForm.querySelector('input[name="page"]');
|
||||
const searchInput = searchForm.querySelector('input[name="keyword"]');
|
||||
|
||||
// API Card Click Handlers
|
||||
@@ -230,6 +244,9 @@
|
||||
groupIdsInput.value = '';
|
||||
}
|
||||
|
||||
// 카테고리 변경 시 1페이지로 리셋
|
||||
if (pageInput) pageInput.value = '1';
|
||||
|
||||
// Submit form
|
||||
searchForm.submit();
|
||||
});
|
||||
@@ -239,6 +256,7 @@
|
||||
searchInput.addEventListener('keypress', function (e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (pageInput) pageInput.value = '1';
|
||||
searchForm.submit();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -238,10 +238,8 @@
|
||||
</span>
|
||||
</p>
|
||||
<div class="card-illustration">
|
||||
<!-- 서비스별 아이콘 매핑 - base64 인코딩된 이미지 사용 -->
|
||||
<img
|
||||
th:src="${service.mainIcon != null and !#strings.isEmpty(service.mainIcon)} ? ${service.mainIcon} : @{/img/api_icon_default.png}"
|
||||
th:alt="${service.groupName}">
|
||||
<!-- 서비스별 아이콘: base64 인라인 대신 그룹 id 기준 스트리밍 엔드포인트(캐시 가능) 사용 -->
|
||||
<img th:src="@{/api-services/{id}/icon(id=${service.id})}" th:alt="${service.groupName}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,9 +558,6 @@
|
||||
</th:block>
|
||||
</body>
|
||||
<th:block layout:fragment="contentScript">
|
||||
<!-- 메인 페이지 전용 스크립트 모듈 추가 -->
|
||||
<script th:src="@{/js/main.js}"></script>
|
||||
|
||||
<!-- 로그인 후 처리 스크립트 -->
|
||||
<script th:src="@{/js/login-success-handler.js}"></script>
|
||||
<script th:inline="javascript">
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<label class="dt-label">API 목록</label>
|
||||
<div class="dt-api-list-box">
|
||||
<div class="dt-api-item" th:each="api : ${apiKey.apiList}">
|
||||
<span class="dt-api-name" th:text="${api.apiDesc}">추가된 API 1</span>
|
||||
<a class="dt-api-name" th:href="@{/apis/detail(id=${api.apiId})}" th:text="${api.apiDesc}">추가된 API 1</a>
|
||||
<span class="dt-api-status-badge">승인</span>
|
||||
</div>
|
||||
<div class="dt-empty-message" th:if="${apiKey.apiList == null or apiKey.apiList.isEmpty()}">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
th:classappend="${isInvited ? 'input-readonly' : ''}" maxlength="50" th:value="${domain}"
|
||||
th:readonly="${isInvited}" th:placeholder="#{portalUser.Register.domain}">
|
||||
</div>
|
||||
<button type="button" class="btn-action-primary md" th:classappend="${isInvited ? 'hidden' : ''}">
|
||||
<button type="button" class="btn-action-primary md btn_check_email" th:classappend="${isInvited ? 'hidden' : ''}">
|
||||
중복체크
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
<option th:each="site : ${relatedSites}" th:value="${site.url}" th:text="${site.name}"></option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
||||
<!-- 고객센터 연락처: PortalProperty(Portal/customer.center.contact). 값이 공백이면 미노출 -->
|
||||
<p class="footer-contact" th:if="${!#strings.isEmpty(#strings.trim(customerCenterContact))}"
|
||||
th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
|
||||
<body>
|
||||
<th:block th:fragment="headerFragment(headerClass)">
|
||||
<!-- 활성 프로파일 표시(prod 제외). 데스크톱은 로고 옆 뱃지, 모바일/태블릿은 최상단 floating 바(env-badge 는 그때 숨김) -->
|
||||
<div th:if="${activeProfileBadge != null}" class="env-strip" th:text="${activeProfileBadge}">dev</div>
|
||||
<!-- Global Header Container -->
|
||||
<header class="global-header" th:classappend="${headerClass}">
|
||||
<div class="container">
|
||||
@@ -23,6 +25,7 @@
|
||||
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
|
||||
</a>
|
||||
<a th:href="@{/}" class="mobile-logo-text" style="font-size: 22px; font-weight: 700; color: #212529; text-decoration: none; margin-left: 8px; vertical-align: middle;">API Portal</a>
|
||||
<span th:if="${activeProfileBadge != null}" class="env-badge" th:text="${activeProfileBadge}">dev</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import javax.persistence.EntityManager;
|
||||
import javax.persistence.Query;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class OrphanCleanupServiceTest {
|
||||
|
||||
@Mock private Environment environment;
|
||||
@Mock private EntityManager entityManager;
|
||||
@Mock private Query query;
|
||||
|
||||
private OrphanCleanupService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new OrphanCleanupService(environment);
|
||||
ReflectionTestUtils.setField(service, "entityManager", entityManager);
|
||||
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false);
|
||||
when(entityManager.createNativeQuery(org.mockito.ArgumentMatchers.anyString())).thenReturn(query);
|
||||
when(query.executeUpdate()).thenReturn(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sweep_runsAllThirteenTablesAndReportsCounts() {
|
||||
when(query.executeUpdate()).thenReturn(3);
|
||||
|
||||
TestCleanupResult result = service.sweep();
|
||||
|
||||
assertEquals(13, result.getDeletedCounts().size());
|
||||
result.getDeletedCounts().values().forEach(count -> assertEquals(3L, count));
|
||||
}
|
||||
|
||||
@Test
|
||||
void sweep_prodProfile_throws() {
|
||||
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.sweep());
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
package com.eactive.apim.portal.djb.testcleanup.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.eactive.apim.portal.app.repository.CredentialRepository;
|
||||
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
|
||||
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
|
||||
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
|
||||
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
|
||||
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
|
||||
import com.eactive.apim.portal.user.repository.UserLogRepository;
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class TestCleanupServiceTest {
|
||||
|
||||
@Mock private Environment environment;
|
||||
@Mock private PortalOrgRepository portalOrgRepository;
|
||||
@Mock private PortalUserRepository portalUserRepository;
|
||||
@Mock private InquiryRepository inquiryRepository;
|
||||
@Mock private InquiryCommentRepository inquiryCommentRepository;
|
||||
@Mock private PartnershipApplicationRepository partnershipApplicationRepository;
|
||||
@Mock private UserRoleHistoryRepository userRoleHistoryRepository;
|
||||
@Mock private PortalUserPrivacyAgreementRepository portalUserPrivacyAgreementRepository;
|
||||
@Mock private UserPasswordHistoryRepository userPasswordHistoryRepository;
|
||||
@Mock private UserLogRepository userLogRepository;
|
||||
@Mock private CredentialRepository credentialRepository;
|
||||
@Mock private WebhookRequestRepository webhookRequestRepository;
|
||||
@Mock private WebhookRequestApiRepository webhookRequestApiRepository;
|
||||
@Mock private WebhookRequestEventRepository webhookRequestEventRepository;
|
||||
@Mock private WebhookService webhookService;
|
||||
@Mock private TestCleanupNativeQueries nativeQueries;
|
||||
|
||||
private TestCleanupService service;
|
||||
|
||||
private static final String ORG_ID = "ORG1";
|
||||
private static final String USER_ID = "USER1";
|
||||
private static final String LOGIN_ID = "user1@test.com";
|
||||
private static final String COMP_REG_NO = "1234567890";
|
||||
private static final String EMAIL = "user1@test.com";
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new TestCleanupService(environment, portalOrgRepository, portalUserRepository,
|
||||
inquiryRepository, inquiryCommentRepository, partnershipApplicationRepository,
|
||||
userRoleHistoryRepository, portalUserPrivacyAgreementRepository, userPasswordHistoryRepository,
|
||||
userLogRepository, credentialRepository, webhookRequestRepository, webhookRequestApiRepository,
|
||||
webhookRequestEventRepository, webhookService, nativeQueries);
|
||||
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false);
|
||||
}
|
||||
|
||||
private PortalUser user() {
|
||||
PortalUser u = new PortalUser();
|
||||
u.setId(USER_ID);
|
||||
u.setLoginId(LOGIN_ID);
|
||||
return u;
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOrgCascade_notFound() {
|
||||
when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.empty());
|
||||
|
||||
TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO);
|
||||
|
||||
assertFalse(result.isFound());
|
||||
assertTrue(result.getDeletedCounts().isEmpty());
|
||||
verifyNoInteractions(webhookService, portalUserRepository);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOrgCascade_found_cascadesUsersAndOrg() {
|
||||
PortalOrg org = org();
|
||||
PortalUser member = user();
|
||||
when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.of(org));
|
||||
when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.singletonList(member));
|
||||
when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.empty());
|
||||
when(nativeQueries.deleteCredentialApiByOrgId(ORG_ID)).thenReturn(2);
|
||||
when(credentialRepository.deleteByOrgid(ORG_ID)).thenReturn(1L);
|
||||
|
||||
TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO);
|
||||
|
||||
assertTrue(result.isFound());
|
||||
assertEquals(ORG_ID, result.getTargetId());
|
||||
assertEquals(1L, result.getDeletedCounts().get("PTL_USER"));
|
||||
assertEquals(2L, result.getDeletedCounts().get("PTL_CREDENTIAL_API"));
|
||||
assertEquals(1L, result.getDeletedCounts().get("PTL_CREDENTIAL"));
|
||||
assertEquals(1L, result.getDeletedCounts().get("PTL_ORG"));
|
||||
assertEquals(0L, result.getDeletedCounts().get("PTL_WEBHOOK_REQ"));
|
||||
verify(inquiryCommentRepository).deleteByInquiry_Inquirer_Id(USER_ID);
|
||||
verify(inquiryRepository).deleteByInquirer_Id(USER_ID);
|
||||
verify(userRoleHistoryRepository).deleteByUserId(LOGIN_ID);
|
||||
verify(userPasswordHistoryRepository).deleteByUserId(USER_ID);
|
||||
verify(portalUserRepository).delete(member);
|
||||
verify(webhookService, never()).delete(anyLong(), anyString());
|
||||
verify(portalOrgRepository).delete(org);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOrgCascade_withWebhook_delegatesToWebhookService() {
|
||||
PortalOrg org = org();
|
||||
WebhookRequest webhook = new WebhookRequest();
|
||||
webhook.setId(99L);
|
||||
when(portalOrgRepository.findByCompRegNo(COMP_REG_NO)).thenReturn(Optional.of(org));
|
||||
when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.emptyList());
|
||||
when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.of(webhook));
|
||||
when(webhookRequestApiRepository.findByWebhookReqId(99L)).thenReturn(Collections.emptyList());
|
||||
when(webhookRequestEventRepository.findByWebhookReqId(99L)).thenReturn(Collections.emptyList());
|
||||
|
||||
TestCleanupResult result = service.deleteOrgCascade(COMP_REG_NO);
|
||||
|
||||
verify(webhookService, times(1)).delete(99L, ORG_ID);
|
||||
assertEquals(1L, result.getDeletedCounts().get("PTL_WEBHOOK_REQ"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserCascade_notFound() {
|
||||
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.empty());
|
||||
|
||||
TestCleanupResult result = service.deleteUserCascade(EMAIL);
|
||||
|
||||
assertFalse(result.isFound());
|
||||
assertTrue(result.getDeletedCounts().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserCascade_found_deletesOrphanDataAndUser() {
|
||||
PortalUser target = user();
|
||||
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(target));
|
||||
|
||||
TestCleanupResult result = service.deleteUserCascade(EMAIL);
|
||||
|
||||
assertTrue(result.isFound());
|
||||
assertEquals(USER_ID, result.getTargetId());
|
||||
verify(inquiryCommentRepository).deleteByInquiry_Inquirer_Id(USER_ID);
|
||||
verify(inquiryRepository).deleteByInquirer_Id(USER_ID);
|
||||
verify(partnershipApplicationRepository).deleteByCreatedBy(USER_ID);
|
||||
verify(userRoleHistoryRepository).deleteByUserId(LOGIN_ID);
|
||||
verify(portalUserPrivacyAgreementRepository).deleteByCreatedBy(USER_ID);
|
||||
verify(userPasswordHistoryRepository).deleteByUserId(USER_ID);
|
||||
verify(userLogRepository).deleteByLoginId(LOGIN_ID);
|
||||
verify(portalUserRepository).delete(target);
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteOrgCascade_prodProfile_throws() {
|
||||
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.deleteOrgCascade(COMP_REG_NO));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteUserCascade_prodProfile_throws() {
|
||||
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(true);
|
||||
|
||||
assertThrows(IllegalStateException.class, () -> service.deleteUserCascade(EMAIL));
|
||||
}
|
||||
|
||||
private PortalOrg org() {
|
||||
PortalOrg o = new PortalOrg();
|
||||
o.setId(ORG_ID);
|
||||
return o;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user