API 해지 팝업 추가 및 사용자 오류 메시지 처리 개선
- API 해지 신청 팝업 UI 및 로직 구현 - 사용자 친화적 예외 처리 헬퍼(UserErrorMessageResolver) 추가 - Webhook 신청/수정, 글로벌 예외 처리에 사용자 메시지 연동
This commit is contained in:
@@ -11,11 +11,11 @@ import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.apps.app.dto.ApiKeyRegistrationDTO;
|
||||
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO;
|
||||
import com.eactive.apim.portal.apps.app.dto.ClientDTO;
|
||||
import com.eactive.apim.portal.apps.app.service.AdminGatewayClient;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties;
|
||||
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService;
|
||||
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.ApiServiceHelper;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
@@ -84,7 +84,6 @@ public class MyAppController {
|
||||
private final ApiService apiService;
|
||||
private final ApiServiceHelper apiServiceHelper;
|
||||
private final FileTypeDetector fileTypeDetector;
|
||||
private final AdminGatewayClient adminGatewayClient;
|
||||
private final TwoFactorService twoFactorService;
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
@@ -184,6 +183,7 @@ public class MyAppController {
|
||||
|
||||
model.addAttribute("apiKey", apiKey);
|
||||
model.addAttribute("secretAvailable", secretAvailable);
|
||||
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
|
||||
|
||||
return new ModelAndView(CREDENTIAL_DETAIL);
|
||||
}
|
||||
@@ -239,10 +239,12 @@ public class MyAppController {
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key를 삭제합니다.
|
||||
* AJAX 요청을 지원하기 위해 @ResponseBody를 사용하여 JSON 응답 반환
|
||||
* API 이용 해지를 신청합니다. (AppRequestType.DELETE 결재 신청 생성)
|
||||
* 즉시 차단/삭제하지 않으며, eapim-admin 관리자 승인 시점에 GW 차단/삭제와
|
||||
* PTL_CREDENTIAL 삭제가 실행됩니다. 승인 전까지 API는 정상 동작합니다.
|
||||
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#APP_KEY_DELETE} 인터셉터 가드)가 담당합니다.
|
||||
*
|
||||
* @param requestData 요청 데이터 (clientId와 type 포함)
|
||||
* @param requestData 요청 데이터 (clientId, reason)
|
||||
* @return 성공/실패 결과를 담은 Map
|
||||
*/
|
||||
@PostMapping("/api_key_delete")
|
||||
@@ -258,38 +260,44 @@ public class MyAppController {
|
||||
return result;
|
||||
}
|
||||
|
||||
String reason = requestData.get("reason");
|
||||
if (reason == null || reason.trim().isEmpty()) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "해지 사유를 입력해 주세요.");
|
||||
return result;
|
||||
}
|
||||
if (reason.length() > 1000) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "해지 사유는 1000자 이내로 입력해 주세요.");
|
||||
return result;
|
||||
}
|
||||
|
||||
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||
String orgId = user.getPortalOrg().getId();
|
||||
|
||||
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지)
|
||||
// 1. 소유권 확인 (다른 조직의 인증키 해지 방지)
|
||||
if (appServiceFacade.getApiKey(orgId, clientId) == null) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "해당 인증키를 찾을 수 없습니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다.
|
||||
// 2. 해지 신청 생성 + 결재 개시 (GW/credential 은 승인 시점에 admin 이 처리)
|
||||
try {
|
||||
adminGatewayClient.blockClient(clientId);
|
||||
} catch (Exception e) {
|
||||
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
|
||||
appServiceFacade.createDeleteRequest(clientId, reason.trim(), user.getPortalOrg());
|
||||
} catch (IllegalStateException e) {
|
||||
result.put("success", false);
|
||||
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요.");
|
||||
result.put("msg", e.getMessage());
|
||||
return result;
|
||||
}
|
||||
|
||||
// 3. GW 차단 성공 시에만 포털 credential 삭제
|
||||
try {
|
||||
appServiceFacade.deleteApp(orgId, clientId);
|
||||
} catch (Exception e) {
|
||||
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e);
|
||||
log.error("API 이용 해지 신청 실패 - clientId={}", clientId, e);
|
||||
result.put("success", false);
|
||||
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage());
|
||||
result.put("msg", UserErrorMessageResolver.resolveAsHtml(e));
|
||||
return result;
|
||||
}
|
||||
|
||||
result.put("success", true);
|
||||
result.put("msg", "API Key가 삭제되었습니다.");
|
||||
result.put("msg", "해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import java.io.IOException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -64,7 +65,11 @@ public class AppServiceFacade {
|
||||
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
|
||||
|
||||
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId());
|
||||
return clients.stream().map(credentialMapper::toVo).collect(Collectors.toList());
|
||||
// 최근 수정(발급/변경) 순으로 정렬. 수정일 없는 건은 뒤로.
|
||||
return clients.stream()
|
||||
.sorted(Comparator.comparing(Credential::getModifiedon,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())))
|
||||
.map(credentialMapper::toVo).collect(Collectors.toList());
|
||||
|
||||
}
|
||||
|
||||
@@ -76,9 +81,26 @@ public class AppServiceFacade {
|
||||
// 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
|
||||
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
|
||||
|
||||
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
|
||||
appRequests.sort(Comparator.comparingInt(this::pendingStatusRank)
|
||||
.thenComparing(AppRequest::getCreatedDate, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
|
||||
return appRequests;
|
||||
}
|
||||
|
||||
private int pendingStatusRank(AppRequest request) {
|
||||
if (request.getApproval() == null) {
|
||||
return 3;
|
||||
}
|
||||
if (request.getApproval().getApprovalStatus() instanceof ProcessingState) {
|
||||
return 1;
|
||||
}
|
||||
if (request.getApproval().getApprovalStatus() instanceof RequestedState) {
|
||||
return 2;
|
||||
}
|
||||
return 3;
|
||||
}
|
||||
|
||||
public ClientDTO getApiKey(String orgid, String clientId) {
|
||||
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
|
||||
}
|
||||
@@ -138,13 +160,70 @@ public class AppServiceFacade {
|
||||
approvalService.beginApproval(approvalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 이용 해지(DELETE) 결재 신청을 생성하고 결재를 개시합니다.
|
||||
* GW 차단/삭제와 PTL_CREDENTIAL 삭제는 여기서 하지 않으며,
|
||||
* eapim-admin 승인 시점에 PortalAppApprovalListener 가 수행합니다.
|
||||
*
|
||||
* @throws IllegalStateException 중복 신청, 변경 신청 진행 중, 승인라인 미등록 등 사용자에게 안내할 상황
|
||||
*/
|
||||
public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) {
|
||||
// 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드
|
||||
List<AppRequest> related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE));
|
||||
for (AppRequest r : related) {
|
||||
if (r.getApproval() == null) {
|
||||
continue;
|
||||
}
|
||||
boolean inProgress = r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||
|| r.getApproval().getApprovalStatus() instanceof ProcessingState;
|
||||
if (!inProgress) {
|
||||
continue;
|
||||
}
|
||||
if (AppRequestType.DELETE.equals(r.getType())) {
|
||||
throw new IllegalStateException("이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요.");
|
||||
}
|
||||
throw new IllegalStateException("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요.");
|
||||
}
|
||||
|
||||
// 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다)
|
||||
AppRequestDTO dto = new AppRequestDTO();
|
||||
dto.setType(AppRequestType.DELETE);
|
||||
dto.setClientId(clientId);
|
||||
dto.setReason(reason);
|
||||
dto.setOrg(portalOrgMapper.toVo(portalOrg));
|
||||
|
||||
AppRequestDTO saved = createAppRequest(dto);
|
||||
|
||||
// 3. 승인라인 미등록이면 approval 이 null — 결재 없는 해지 신청은 만들지 않는다(트랜잭션 롤백)
|
||||
if (saved.getApproval() == null || saved.getApproval().getId() == null) {
|
||||
throw new IllegalStateException("APP 승인라인이 등록되어 있지 않아 해지를 신청할 수 없습니다. 관리자에게 문의해 주세요.");
|
||||
}
|
||||
|
||||
beginApproval(saved.getApproval().getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다.
|
||||
* 상세 화면의 해지 버튼 비활성화에 사용됩니다.
|
||||
*/
|
||||
public boolean hasPendingDeleteRequest(String clientId) {
|
||||
return appRequestRepository.findAllByClientIdsContainsAndTypeIsIn(
|
||||
clientId, Arrays.asList(AppRequestType.DELETE)).stream()
|
||||
.anyMatch(r -> r.getApproval() != null
|
||||
&& (r.getApproval().getApprovalStatus() instanceof RequestedState
|
||||
|| r.getApproval().getApprovalStatus() instanceof ProcessingState));
|
||||
}
|
||||
|
||||
public void cancelApiRequest(String id, PortalOrg portalOrg) {
|
||||
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
|
||||
if (request.getApproval() == null) {
|
||||
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
|
||||
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
|
||||
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
|
||||
if (StringUtils.isNotBlank(request.getClientId())) {
|
||||
// DELETE(해지) 신청은 살아있는 클라이언트가 대상이므로 취소 시 GW 를 건드리면 안 된다.
|
||||
if (StringUtils.isNotBlank(request.getClientId())
|
||||
&& !AppRequestType.DELETE.equals(request.getType())) {
|
||||
try {
|
||||
adminGatewayClient.blockClient(request.getClientId());
|
||||
} catch (Exception e) {
|
||||
@@ -343,18 +422,4 @@ public class AppServiceFacade {
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* API Key(Credential)를 즉시 삭제합니다.
|
||||
* 승인 프로세스 없이 바로 삭제 처리됩니다.
|
||||
*
|
||||
* @param orgId 조직 ID
|
||||
* @param clientId 삭제할 클라이언트 ID
|
||||
* @throws NotFoundException 클라이언트를 찾을 수 없는 경우
|
||||
*/
|
||||
public void deleteApp(String orgId, String clientId) {
|
||||
Credential credential = credentialRepository.findByClientidAndOrgid(clientId, orgId)
|
||||
.orElseThrow(() -> new NotFoundException("Client not found: " + clientId));
|
||||
|
||||
credentialRepository.delete(credential);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-22
@@ -2,8 +2,6 @@ package com.eactive.apim.portal.common.exception;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
@@ -13,11 +11,12 @@ import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.config.PortalProperties;
|
||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.core.env.Profiles;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
@@ -39,6 +38,7 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
private final Logger log = LoggerFactory.getLogger(getClass());
|
||||
private final PortalProperties portalProperties;
|
||||
private final Environment environment;
|
||||
|
||||
@ExceptionHandler(value = NotFoundException.class)
|
||||
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
|
||||
@@ -129,10 +129,10 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(value = {IllegalArgumentException.class})
|
||||
public ModelAndView handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException ex) {
|
||||
log.error(ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.addObject("errorMessage", ex.getMessage());
|
||||
modelAndView.setViewName("error");
|
||||
log.error("잘못된 요청 - uri={}, message={}", request.getRequestURI(), ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다.");
|
||||
modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex));
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
@@ -146,34 +146,40 @@ public class PortalGlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(value = HttpMediaTypeNotSupportedException.class)
|
||||
public ModelAndView handleHttpMediaTypeNotSupportedException(HttpServletRequest request, HttpMediaTypeNotSupportedException ex) {
|
||||
log.error(ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.addObject("errorMessage", ex.getMessage());
|
||||
modelAndView.setViewName("error");
|
||||
log.error("지원하지 않는 요청 형식 - uri={}, message={}", request.getRequestURI(), ex.getMessage());
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다.");
|
||||
modelAndView.addObject("errorDescription", "지원하지 않는 요청 형식입니다.\n잠시 후 다시 시도해 주세요.");
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리되지 않은 예외. 예외 원문은 로그에만 남기고 화면에는 사용자 안내 문구를 표시한다.
|
||||
* 원문(클래스명/메시지)은 운영(prod) 이외 환경의 상세 영역에만 노출한다.
|
||||
*/
|
||||
@ExceptionHandler(value = Exception.class)
|
||||
public ModelAndView handleException(HttpServletRequest request, Exception ex) {
|
||||
Map<String, Object> params = new HashMap<>(2);
|
||||
params.put("errorMessage", ex.getLocalizedMessage());
|
||||
params.put("stackTrace", ExceptionUtils.getStackTrace(ex)); // Apache Commons Lang
|
||||
params.put("requestURL", request.getRequestURL().toString());
|
||||
|
||||
String mapAsString = request.getParameterMap().entrySet()
|
||||
String requestParams = request.getParameterMap().entrySet()
|
||||
.stream()
|
||||
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
params.put("requestParams", mapAsString);
|
||||
log.error("Exception occurred - url={}, params={}", request.getRequestURL(), requestParams, ex);
|
||||
|
||||
log.error("Exception occurred: ", ex);
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
modelAndView.addObject("errorMessage", ex.getMessage());
|
||||
modelAndView.setViewName("error");
|
||||
ModelAndView modelAndView = new ModelAndView("error");
|
||||
modelAndView.addObject("errorTitle", "서비스 처리 중 오류가 발생했습니다.");
|
||||
modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex));
|
||||
if (!isProd()) {
|
||||
modelAndView.addObject("errorMessage", ex.getClass().getName() + ": " + ex.getMessage());
|
||||
modelAndView.addObject("activeProfile", String.join(", ", environment.getActiveProfiles()));
|
||||
}
|
||||
return modelAndView;
|
||||
}
|
||||
|
||||
private boolean isProd() {
|
||||
return environment.acceptsProfiles(Profiles.of("prod"));
|
||||
}
|
||||
|
||||
@ExceptionHandler(value = InvalidFileException.class)
|
||||
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
|
||||
ModelAndView modelAndView = new ModelAndView();
|
||||
|
||||
+6
-2
@@ -53,10 +53,14 @@ public class PortalRestExceptionHandler {
|
||||
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
/**
|
||||
* 처리되지 않은 예외. 원문 메시지는 로그에만 남기고, 화면에는 사용자 안내 문구를 내려준다.
|
||||
* (JTA 롤백/DB 락 같은 인프라 예외의 영문 원문이 팝업에 그대로 노출되지 않도록)
|
||||
*/
|
||||
@ExceptionHandler(value = Exception.class)
|
||||
public ResponseEntity<ResponseDTO> handleUnknownxception(HttpServletRequest request, Exception ex) {
|
||||
ex.printStackTrace();
|
||||
ResponseDTO response = new ResponseDTO(500, "500", ex.getMessage());
|
||||
log.error("처리되지 않은 예외 - uri={}", request.getRequestURI(), ex);
|
||||
ResponseDTO response = new ResponseDTO(500, "500", UserErrorMessageResolver.resolveAsHtml(ex));
|
||||
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
import org.springframework.dao.CannotAcquireLockException;
|
||||
import org.springframework.dao.DataAccessResourceFailureException;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.dao.PessimisticLockingFailureException;
|
||||
import org.springframework.dao.QueryTimeoutException;
|
||||
import org.springframework.transaction.CannotCreateTransactionException;
|
||||
import org.springframework.transaction.TransactionException;
|
||||
|
||||
/**
|
||||
* 예외를 사용자에게 보여줄 안내 문구로 변환한다.
|
||||
*
|
||||
* <p>JTA 롤백/DB 락/타임아웃 같은 인프라 예외는 원문(예: {@code "JTA transaction unexpectedly rolled back
|
||||
* (maybe due to a timeout); nested exception is javax.transaction.RollbackException"})이 그대로
|
||||
* 팝업·에러 화면에 노출되면 사용자가 이해할 수 없고 내부 구조까지 드러난다. 이 클래스가 정해진 한글 문구로 치환한다.
|
||||
*
|
||||
* <p>서비스 코드가 의도적으로 던진 한글 안내문(예: {@code IllegalStateException("이미 초대가 진행 중입니다.")})은
|
||||
* 그대로 유지한다. 판단은 {@link #looksUserFacing(String)} 의 휴리스틱(한글 포함 + 기술 토큰 없음)을 따른다.
|
||||
*
|
||||
* <p>반환 문구는 평문이며 줄바꿈은 {@code \n} 이다. HTML 팝업으로 내려줄 때는 {@link #toHtml(String)} 을 쓴다.
|
||||
*/
|
||||
public final class UserErrorMessageResolver {
|
||||
|
||||
/** 원인을 특정할 수 없을 때의 기본 문구. */
|
||||
public static final String DEFAULT_MESSAGE =
|
||||
"요청을 처리하는 중 오류가 발생했습니다.\n잠시 후 다시 시도해 주세요.";
|
||||
|
||||
private static final String ROLLBACK_MESSAGE =
|
||||
"요청 처리가 정상적으로 끝나지 않아 변경 내용이 저장되지 않았습니다.\n잠시 후 다시 시도해 주세요. 같은 문제가 반복되면 관리자에게 문의해 주세요.";
|
||||
|
||||
private static final String TIMEOUT_MESSAGE =
|
||||
"처리 시간이 초과되어 요청이 취소되었습니다.\n잠시 후 다시 시도해 주세요.";
|
||||
|
||||
private static final String CONFLICT_MESSAGE =
|
||||
"이미 등록된 정보이거나 다른 정보와 충돌하여 저장할 수 없습니다.\n입력 내용을 확인해 주세요.";
|
||||
|
||||
private static final String LOCK_MESSAGE =
|
||||
"다른 사용자가 동일한 정보를 변경하고 있습니다.\n잠시 후 다시 시도해 주세요.";
|
||||
|
||||
private static final String STALE_MESSAGE =
|
||||
"다른 사용자가 먼저 정보를 변경했습니다.\n화면을 새로 고친 뒤 다시 시도해 주세요.";
|
||||
|
||||
private static final String CONNECTION_MESSAGE =
|
||||
"시스템 연결이 원활하지 않아 요청을 처리하지 못했습니다.\n잠시 후 다시 시도해 주세요.";
|
||||
|
||||
/** 원문 노출을 막아야 하는 기술 토큰. 메시지에 하나라도 있으면 사용자 안내문으로 보지 않는다. */
|
||||
private static final String[] TECHNICAL_TOKENS = {
|
||||
"exception", "Exception", "rollback", "Rollback", "transaction", "Transaction",
|
||||
"SQL", "ORA-", "JTA", "JDBC", "Hibernate", "hibernate", "com.eactive", "org.springframework",
|
||||
"javax.", "java.", "oracle.", "at com.", "Caused by", "null pointer", "NullPointer",
|
||||
"constraint", "Constraint", "statement", "Statement", "SocketTimeout", "Connection"
|
||||
};
|
||||
|
||||
private UserErrorMessageResolver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 예외에서 사용자 안내 문구를 만든다.
|
||||
*
|
||||
* @param ex 발생한 예외 (null 허용)
|
||||
* @return 사용자에게 보여줄 평문 문구. 줄바꿈은 {@code \n}
|
||||
*/
|
||||
public static String resolve(Throwable ex) {
|
||||
if (ex == null) {
|
||||
return DEFAULT_MESSAGE;
|
||||
}
|
||||
|
||||
String infraMessage = resolveInfrastructureMessage(ex);
|
||||
if (infraMessage != null) {
|
||||
return infraMessage;
|
||||
}
|
||||
|
||||
// 서비스가 의도적으로 던진 한글 안내문은 그대로 전달
|
||||
String original = ex.getMessage();
|
||||
if (looksUserFacing(original)) {
|
||||
return original;
|
||||
}
|
||||
|
||||
return DEFAULT_MESSAGE;
|
||||
}
|
||||
|
||||
/** {@link #resolve(Throwable)} 결과를 팝업(HTML)용으로 변환한다. */
|
||||
public static String resolveAsHtml(Throwable ex) {
|
||||
return toHtml(resolve(ex));
|
||||
}
|
||||
|
||||
/** 평문 줄바꿈을 {@code <br>} 로 바꾼다. */
|
||||
public static String toHtml(String plainMessage) {
|
||||
if (plainMessage == null) {
|
||||
return null;
|
||||
}
|
||||
return plainMessage.replace("\n", "<br>");
|
||||
}
|
||||
|
||||
/**
|
||||
* 트랜잭션/DB/연결 계열 인프라 예외인지 원인 체인을 따라가며 판별한다.
|
||||
*
|
||||
* @return 해당 문구, 인프라 예외가 아니면 null
|
||||
*/
|
||||
private static String resolveInfrastructureMessage(Throwable ex) {
|
||||
String chainText = causeChainText(ex);
|
||||
|
||||
// 1. 데이터 충돌(유니크/FK/NOT NULL) — 롤백 판정보다 먼저: 롤백 예외가 이를 감싸고 있어도 원인이 더 구체적이다.
|
||||
if (hasType(ex, DataIntegrityViolationException.class)
|
||||
|| containsAny(chainText, "org.hibernate.exception.ConstraintViolationException",
|
||||
"SQLIntegrityConstraintViolationException",
|
||||
"ORA-00001", "ORA-01400", "ORA-02291", "ORA-02292", "ORA-12899")) {
|
||||
return CONFLICT_MESSAGE;
|
||||
}
|
||||
|
||||
// 2. 낙관적 락 충돌
|
||||
if (hasType(ex, OptimisticLockingFailureException.class)
|
||||
|| containsAny(chainText, "OptimisticLockException", "StaleObjectStateException", "StaleStateException")) {
|
||||
return STALE_MESSAGE;
|
||||
}
|
||||
|
||||
// 3. 비관적 락 / 데드락
|
||||
if (hasType(ex, PessimisticLockingFailureException.class)
|
||||
|| hasType(ex, CannotAcquireLockException.class)
|
||||
|| containsAny(chainText, "ORA-00060", "ORA-02049", "ORA-00054", "deadlock")) {
|
||||
return LOCK_MESSAGE;
|
||||
}
|
||||
|
||||
// 4. 타임아웃 (쿼리/소켓/사용자 취소)
|
||||
if (hasType(ex, QueryTimeoutException.class)
|
||||
|| containsAny(chainText, "SocketTimeoutException", "QueryTimeoutException", "ORA-01013")) {
|
||||
return TIMEOUT_MESSAGE;
|
||||
}
|
||||
|
||||
// 5. 연결 실패
|
||||
if (hasType(ex, DataAccessResourceFailureException.class)
|
||||
|| hasType(ex, CannotCreateTransactionException.class)
|
||||
|| containsAny(chainText, "SQLRecoverableException", "ConnectException", "UnknownHostException",
|
||||
"ORA-03113", "ORA-03114", "ORA-12541", "ORA-12170")) {
|
||||
return CONNECTION_MESSAGE;
|
||||
}
|
||||
|
||||
// 6. JTA 롤백 계열 — 원인을 특정하지 못한 트랜잭션 실패
|
||||
if (hasType(ex, TransactionException.class)
|
||||
|| containsAny(chainText, "RollbackException", "HeuristicMixedException", "HeuristicRollbackException",
|
||||
"rolled back", "rollback only")) {
|
||||
// "Transaction set to rollback only" 은 내부 예외가 삼켜진 경우다. Spring 원문에 "maybe due to a
|
||||
// timeout" 이 붙어 있어도 실제 타임아웃이 아니므로 타임아웃 문구를 쓰지 않는다.
|
||||
if (!containsAny(chainText, "rollback only")
|
||||
&& containsAny(chainText, "timeout", "timed out")) {
|
||||
return TIMEOUT_MESSAGE;
|
||||
}
|
||||
return ROLLBACK_MESSAGE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 그대로 사용자에게 보여줘도 되는 안내문인지 판단한다.
|
||||
* 한글이 포함되고 기술 토큰이 없어야 한다.
|
||||
*/
|
||||
private static boolean looksUserFacing(String message) {
|
||||
if (message == null) {
|
||||
return false;
|
||||
}
|
||||
String trimmed = message.trim();
|
||||
if (trimmed.isEmpty() || trimmed.length() > 200) {
|
||||
return false;
|
||||
}
|
||||
if (!containsHangul(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
for (String token : TECHNICAL_TOKENS) {
|
||||
if (trimmed.contains(token)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean containsHangul(String text) {
|
||||
for (int i = 0; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (c >= 0xAC00 && c <= 0xD7A3) { // 한글 음절
|
||||
return true;
|
||||
}
|
||||
if (c >= 0x1100 && c <= 0x11FF) { // 한글 자모
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 원인 체인의 클래스명 + 메시지를 한 문자열로 모은다(순환 참조 방지). */
|
||||
private static String causeChainText(Throwable ex) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
Throwable current = ex;
|
||||
int depth = 0;
|
||||
while (current != null && depth < 10) {
|
||||
sb.append(current.getClass().getName());
|
||||
if (current.getMessage() != null) {
|
||||
sb.append(' ').append(current.getMessage());
|
||||
}
|
||||
sb.append('\n');
|
||||
Throwable cause = current.getCause();
|
||||
if (cause == current) {
|
||||
break;
|
||||
}
|
||||
current = cause;
|
||||
depth++;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static boolean hasType(Throwable ex, Class<? extends Throwable> type) {
|
||||
Throwable current = ex;
|
||||
int depth = 0;
|
||||
while (current != null && depth < 10) {
|
||||
if (type.isInstance(current)) {
|
||||
return true;
|
||||
}
|
||||
Throwable cause = current.getCause();
|
||||
if (cause == current) {
|
||||
return false;
|
||||
}
|
||||
current = cause;
|
||||
depth++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean containsAny(String text, String... keywords) {
|
||||
for (String keyword : keywords) {
|
||||
if (text.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.controller;
|
||||
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||
@@ -163,8 +164,8 @@ public class WebhookController {
|
||||
redirectAttributes.addFlashAttribute("registrationSuccess", true);
|
||||
return new ModelAndView("redirect:/webhook/register/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
log.warn("Webhook 신청 실패 orgId={}", currentOrgId(), e);
|
||||
redirectAttributes.addFlashAttribute("error", UserErrorMessageResolver.resolve(e));
|
||||
return new ModelAndView("redirect:/webhook/register/step2");
|
||||
}
|
||||
}
|
||||
@@ -270,8 +271,8 @@ public class WebhookController {
|
||||
redirectAttributes.addFlashAttribute("modifySuccess", true);
|
||||
return new ModelAndView("redirect:/webhook/modify/step3");
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||
log.warn("Webhook 수정 실패 orgId={}", currentOrgId(), e);
|
||||
redirectAttributes.addFlashAttribute("error", UserErrorMessageResolver.resolve(e));
|
||||
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +246,110 @@ const customPopups = {
|
||||
$('#passwordPopupError').removeClass('show');
|
||||
$('#passwordPopupInput').removeClass('error');
|
||||
},
|
||||
/**
|
||||
* API 이용 해지 신청 팝업 표시 (경고문 + 사유 필수 — 본인 확인은 step-up 2FA가 담당)
|
||||
* @param {Object} options - 팝업 옵션
|
||||
* @param {Function} options.onConfirm - 해지 신청 버튼 클릭 시 호출되는 콜백 (파라미터: reason)
|
||||
* @param {Function} options.onCancel - 취소 버튼 클릭 시 호출되는 콜백 (선택사항)
|
||||
*/
|
||||
showTerminateRequest: function (options) {
|
||||
options = options || {};
|
||||
|
||||
const onConfirm = options.onConfirm;
|
||||
const onCancel = options.onCancel;
|
||||
|
||||
// 입력 필드 및 에러 초기화
|
||||
$('#terminateReasonInput').val('').removeClass('error');
|
||||
$('#terminatePopupError').removeClass('show').text('');
|
||||
|
||||
// 팝업 표시 (modal 구조 사용)
|
||||
$('#terminateRequestPopup').show();
|
||||
setTimeout(function() {
|
||||
$('#terminateModalBackdrop').addClass('show');
|
||||
$('#terminateModal').addClass('show');
|
||||
}, 10);
|
||||
|
||||
// Body 스크롤 방지
|
||||
$('body').css('overflow', 'hidden');
|
||||
|
||||
// 사유 입력 필드에 포커스
|
||||
setTimeout(function() {
|
||||
$('#terminateReasonInput').focus();
|
||||
}, 350);
|
||||
|
||||
// 확인 버튼 이벤트 (기존 이벤트 제거 후 재등록)
|
||||
$('#terminatePopupConfirmButton').off('click').on('click', function () {
|
||||
const reason = $('#terminateReasonInput').val().trim();
|
||||
|
||||
if (!reason) {
|
||||
customPopups.showTerminateError('해지 사유를 입력해주세요.');
|
||||
$('#terminateReasonInput').addClass('error').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm(reason);
|
||||
}
|
||||
});
|
||||
|
||||
// 취소 버튼 이벤트
|
||||
$('#terminatePopupCancelButton').off('click').on('click', function () {
|
||||
customPopups.hideTerminateRequest();
|
||||
if (typeof onCancel === 'function') {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
// 닫기 버튼 이벤트
|
||||
$('#terminatePopupCloseButton').off('click').on('click', function () {
|
||||
customPopups.hideTerminateRequest();
|
||||
if (typeof onCancel === 'function') {
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
|
||||
// 입력 시 에러 초기화
|
||||
$('#terminateReasonInput').off('input').on('input', function () {
|
||||
$(this).removeClass('error');
|
||||
$('#terminatePopupError').removeClass('show').text('');
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 해지 신청 팝업 숨기기
|
||||
*/
|
||||
hideTerminateRequest: function () {
|
||||
// Modal 숨김 애니메이션
|
||||
$('#terminateModalBackdrop').removeClass('show');
|
||||
$('#terminateModal').removeClass('show');
|
||||
|
||||
// 애니메이션 완료 후 숨김
|
||||
setTimeout(function() {
|
||||
$('#terminateRequestPopup').hide();
|
||||
}, 300);
|
||||
|
||||
// Body 스크롤 복원
|
||||
$('body').css('overflow', '');
|
||||
|
||||
// 입력 필드 초기화
|
||||
$('#terminateReasonInput').val('').removeClass('error');
|
||||
$('#terminatePopupError').removeClass('show').text('');
|
||||
|
||||
// 이벤트 리스너 제거
|
||||
$('#terminatePopupConfirmButton').off('click');
|
||||
$('#terminatePopupCancelButton').off('click');
|
||||
$('#terminatePopupCloseButton').off('click');
|
||||
$('#terminateReasonInput').off('input');
|
||||
},
|
||||
|
||||
/**
|
||||
* 해지 신청 팝업 에러 메시지 표시
|
||||
* @param {string} message - 에러 메시지
|
||||
*/
|
||||
showTerminateError: function (message) {
|
||||
$('#terminatePopupError').text(message).addClass('show');
|
||||
},
|
||||
|
||||
/**
|
||||
* 확인 팝업 표시
|
||||
* @param {string} message - 확인 메시지
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
<!-- Form Content -->
|
||||
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
|
||||
th:object="${inquiry}" method="post" enctype="multipart/form-data" class="djb-board-form">
|
||||
<input type="hidden" th:field="*{id}" th:if="${!isNew}">
|
||||
<input type="hidden" th:field="*{id}" th:unless="${isNew}">
|
||||
|
||||
<!-- Subject Field -->
|
||||
<div class="form-group">
|
||||
@@ -85,7 +85,7 @@
|
||||
<input type="file" id="inquiryImage" name="image" class="djb-input"
|
||||
accept="image/png,image/jpeg,image/gif">
|
||||
<small class="form-help-text">jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다.</small>
|
||||
<small th:if="${!isNew and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"
|
||||
<small th:if="${isNew != true and inquiry.attachFile != null and !inquiry.attachFile.isEmpty()}"
|
||||
class="form-help-text">현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.</small>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
const file = fileInput.files[0];
|
||||
|
||||
if (file) {
|
||||
/*[# th:if="${!isInternalUser}"]*/
|
||||
/*[# th:unless="${isInternalUser}"]*/
|
||||
const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png'];
|
||||
const fileExt = '.' + file.name.split('.').pop().toLowerCase();
|
||||
|
||||
|
||||
@@ -32,57 +32,7 @@
|
||||
<!-- App List Container -->
|
||||
<div class="app-list-container-figma">
|
||||
|
||||
<!-- App Requests (Pending) -->
|
||||
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
|
||||
<a class="app-card-figma" th:each="request : ${appRequests}"
|
||||
th:href="@{/clients/app_request_detail(id=${request.id})}">
|
||||
|
||||
<!-- App Icon -->
|
||||
<div class="app-card-icon-box">
|
||||
<img th:if="${request.appIconFileId != null}"
|
||||
th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}" alt="App Icon">
|
||||
<svg th:unless="${request.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24"
|
||||
fill="none" stroke="#64748b" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="app-card-info">
|
||||
<div class="app-card-header">
|
||||
<!-- App Name -->
|
||||
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
|
||||
<!-- Status Badge -->
|
||||
<span class="app-card-badge"
|
||||
th:classappend="${request.approval != null and request.approval.approvalStatus != null and request.approval.approvalStatus.toString() == 'REQUESTED' ? 'badge-requested' : 'badge-pending'}"
|
||||
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인정보 없음'}">
|
||||
승인정보 없음
|
||||
</span>
|
||||
</div>
|
||||
<!-- App Description & Expected Completion Date -->
|
||||
<div class="app-card-footer-row">
|
||||
<p class="app-card-desc"
|
||||
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
|
||||
앱 설명이 여기에 표시됩니다.
|
||||
</p>
|
||||
<!-- Expected date or placeholder to keep structure aligned -->
|
||||
<span class="app-card-expected-date"
|
||||
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
|
||||
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
|
||||
예상 완료일 : 05월 30일 (토)
|
||||
</span>
|
||||
<span class="app-card-expected-date"
|
||||
th:unless="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}">
|
||||
예상 완료일 : -
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</th:block>
|
||||
|
||||
<!-- API Keys (Approved/Inactive) -->
|
||||
<!-- API Keys (Approved/Inactive) — 승인완료 우선 표시 -->
|
||||
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
|
||||
<a class="app-card-figma" th:each="apikey : ${apiKeys}"
|
||||
th:href="@{/clients/credential_detail(id=${apikey.clientid})}">
|
||||
@@ -119,6 +69,59 @@
|
||||
</a>
|
||||
</th:block>
|
||||
|
||||
<!-- App Requests (Pending) — 진행중 → 요청됨, 최근 신청 순 -->
|
||||
<th:block th:if="${appRequests != null and !appRequests.isEmpty()}">
|
||||
<a class="app-card-figma" th:each="request : ${appRequests}"
|
||||
th:href="@{/clients/app_request_detail(id=${request.id})}">
|
||||
|
||||
<!-- App Icon -->
|
||||
<div class="app-card-icon-box">
|
||||
<img th:if="${request.appIconFileId != null}"
|
||||
th:src="@{/file/download(fileSn=1,fileId=${request.appIconFileId})}" alt="App Icon">
|
||||
<svg th:unless="${request.appIconFileId != null}" width="46" height="46" viewBox="0 0 24 24"
|
||||
fill="none" stroke="#64748b" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
||||
<polyline points="21 15 16 10 5 21"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- App Info -->
|
||||
<div class="app-card-info">
|
||||
<div class="app-card-header">
|
||||
<!-- App Name -->
|
||||
<h3 class="app-card-title" th:text="${request.clientName}">앱 이름</h3>
|
||||
<!-- Request Type Badge (해지 신청 구분) -->
|
||||
<span class="app-card-badge badge-pending"
|
||||
th:if="${request.type != null and request.type.name() == 'DELETE'}">해지 신청</span>
|
||||
<!-- Status Badge -->
|
||||
<span class="app-card-badge"
|
||||
th:classappend="${request.approval != null and request.approval.approvalStatus != null and request.approval.approvalStatus.toString() == 'REQUESTED' ? 'badge-requested' : 'badge-pending'}"
|
||||
th:text="${request.approval != null and request.approval.approvalStatus != null ? request.approval.approvalStatus.description : '승인정보 없음'}">
|
||||
승인정보 없음
|
||||
</span>
|
||||
</div>
|
||||
<!-- App Description & Expected Completion Date -->
|
||||
<div class="app-card-footer-row">
|
||||
<p class="app-card-desc"
|
||||
th:text="${request.appDescription != null ? request.appDescription : '설명 없음'}">
|
||||
앱 설명이 여기에 표시됩니다.
|
||||
</p>
|
||||
<!-- Expected date or placeholder to keep structure aligned -->
|
||||
<span class="app-card-expected-date"
|
||||
th:if="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}"
|
||||
th:text="|예상 완료일 : ${#strings.substring(request.approval.expectEndDate,4,6)}월 ${#strings.substring(request.approval.expectEndDate,6,8)}일 (${request.approval.expectEndDateDayOfWeek})|">
|
||||
예상 완료일 : 05월 30일 (토)
|
||||
</span>
|
||||
<span class="app-card-expected-date"
|
||||
th:unless="${request.approval != null and request.approval.expectEndDate != null and #strings.length(request.approval.expectEndDate) >= 8}">
|
||||
예상 완료일 : -
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</th:block>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div class="app-list-empty-figma"
|
||||
th:if="${(appRequests == null or appRequests.isEmpty()) and (apiKeys == null or apiKeys.isEmpty())}">
|
||||
|
||||
@@ -190,8 +190,10 @@
|
||||
<div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
|
||||
<a th:href="@{/clients}" class="dt-btn-gray">목록</a>
|
||||
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red"
|
||||
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)">API 이용 해지</button>
|
||||
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue"
|
||||
th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)"
|
||||
th:disabled="${pendingDeleteRequest}"
|
||||
th:text="${pendingDeleteRequest} ? '해지 승인 대기중' : 'API 이용 해지'">API 이용 해지</button>
|
||||
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" th:unless="${pendingDeleteRequest}" class="dt-btn-blue"
|
||||
th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
|
||||
</div>
|
||||
|
||||
@@ -295,44 +297,44 @@
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
// Delete API Key function - called from button with data-client-id attribute
|
||||
// API 이용 해지 신청 진입 - 경고 + 사유 모달 (본인 확인은 step-up 2FA가 담당)
|
||||
function deleteApiKeyFromButton(button) {
|
||||
var clientId = $(button).data('client-id');
|
||||
|
||||
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function (confirmed) {
|
||||
if (!confirmed) {
|
||||
return;
|
||||
customPopups.showTerminateRequest({
|
||||
onConfirm: function (reason) {
|
||||
doDeleteApiKey(clientId, reason);
|
||||
}
|
||||
doDeleteApiKey(clientId);
|
||||
});
|
||||
}
|
||||
|
||||
// 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||
function doDeleteApiKey(clientId) {
|
||||
// 해지 신청 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
|
||||
function doDeleteApiKey(clientId, reason) {
|
||||
$('.loading-overlay').show();
|
||||
|
||||
$.ajax({
|
||||
url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify({ clientId: clientId }),
|
||||
data: JSON.stringify({ clientId: clientId, reason: reason }),
|
||||
headers: {
|
||||
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
|
||||
}
|
||||
}).done(function (response) {
|
||||
if (response && response.success === false) {
|
||||
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.');
|
||||
customPopups.showTerminateError(response.msg || '해지 신청에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () {
|
||||
customPopups.hideTerminateRequest();
|
||||
customPopups.showAlert(response.msg || '해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.', function () {
|
||||
window.location.href = /*[[@{/clients}]]*/ '/clients';
|
||||
});
|
||||
}).fail(function (jqXHR, textStatus, errorThrown) {
|
||||
if (isStepUpRequired(jqXHR)) {
|
||||
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); });
|
||||
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId, reason); });
|
||||
return;
|
||||
}
|
||||
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
customPopups.showTerminateError('해지 신청 요청 중 오류가 발생했습니다: ' + errorThrown);
|
||||
}).always(function () {
|
||||
$('.loading-overlay').hide();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<section layout:fragment="title">
|
||||
<div class="page-title-banner">
|
||||
<img th:src="@{/img/img_title_bg.png}" alt="개인회원가입" class="title-image">
|
||||
<h1 th:text="${!isInvited ? '개인회원가입' : '법인회원가입'}">개인회원가입</h1>
|
||||
<h1 th:text="${isInvited != true ? '개인회원가입' : '법인회원가입'}">개인회원가입</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<!-- Registration Card Wrapper -->
|
||||
<div class="register-card-wrapper">
|
||||
<!-- Info Notice -->
|
||||
<div class="org-info-notice" th:if="${!isInvited}">
|
||||
<div class="org-info-notice" th:unless="${isInvited}">
|
||||
<div class="notice-icon-wrapper">
|
||||
<svg class="notice-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div th:fragment="authNumberValidation">
|
||||
<div th:if="${!isValid}" class="invalid-feedback d-block">
|
||||
<div th:unless="${isValid}" class="invalid-feedback d-block">
|
||||
올바른 인증번호를 입력해주세요.
|
||||
</div>
|
||||
<div th:if="${isValid}" class="valid-feedback d-block">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div th:fragment="result">
|
||||
<span id="validationResult"
|
||||
th:text="${!isValid} ? '올바른 법인 등록번호 형식이 아닙니다. 앞 6자리, 뒤 7자리의 숫자를 입력해주세요.' : '유효한 법인 등록번호입니다.'"
|
||||
th:class="${!isValid} ? 'invalid-feedback d-block' : 'valid-feedback d-block'">
|
||||
th:text="${isValid != true} ? '올바른 법인 등록번호 형식이 아닙니다. 앞 6자리, 뒤 7자리의 숫자를 입력해주세요.' : '유효한 법인 등록번호입니다.'"
|
||||
th:class="${isValid != true} ? 'invalid-feedback d-block' : 'valid-feedback d-block'">
|
||||
</span>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<div th:fragment="result">
|
||||
<div th:if="${!isValidFormat}" class="invalid-feedback d-block">
|
||||
<div th:unless="${isValidFormat}" class="invalid-feedback d-block">
|
||||
올바른 이메일 형식이 아닙니다.
|
||||
</div>
|
||||
<div th:if="${isValidFormat}">
|
||||
@@ -11,6 +11,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<span id="validationResult" style="display:none;"
|
||||
th:text="${!isValidFormat} ? '올바른 이메일 형식이 아닙니다.' : (${isDuplicate} ? '이미 등록된 이메일입니다.' : '사용 가능한 이메일입니다.')">
|
||||
th:text="${isValidFormat != true} ? '올바른 이메일 형식이 아닙니다.' : (${isDuplicate} ? '이미 등록된 이메일입니다.' : '사용 가능한 이메일입니다.')">
|
||||
</span>
|
||||
</div>
|
||||
@@ -4,4 +4,5 @@
|
||||
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
|
||||
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
|
||||
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
|
||||
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
<!-- views/fragment/popup/terminateRequestPopup.html -->
|
||||
<div th:fragment="terminateRequestPopup" id="terminateRequestPopup" style="display: none;">
|
||||
<!-- Modal Backdrop -->
|
||||
<div class="modal-backdrop" id="terminateModalBackdrop"></div>
|
||||
|
||||
<!-- Modal Wrapper -->
|
||||
<div class="modal" id="terminateModal">
|
||||
<div class="modal-dialog">
|
||||
|
||||
<!-- Modal Header -->
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="terminatePopupTitle">API 이용 해지 신청</h3>
|
||||
<button type="button" class="modal-close" id="terminatePopupCloseButton">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="modal-body">
|
||||
<p id="terminatePopupMessage" style="margin-bottom: 16px; color: #64748B; word-break: keep-all;">
|
||||
해지 신청 후 <strong>관리자 승인 전까지 API는 정상 동작</strong>하며,<br>
|
||||
승인이 완료되면 <strong>인증키가 삭제되고 API 호출이 차단</strong>됩니다.<br>
|
||||
삭제된 인증키는 <strong>복구할 수 없습니다.</strong>
|
||||
</p>
|
||||
|
||||
<!-- Reason Input Field (본인 확인은 step-up 2FA가 담당하므로 비밀번호 입력 없음) -->
|
||||
<div class="pop_input_group">
|
||||
<textarea id="terminateReasonInput"
|
||||
class="pop_input_field"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
placeholder="해지 사유를 입력해주세요 (필수)"
|
||||
style="resize: vertical; min-height: 72px;"></textarea>
|
||||
<div id="terminatePopupError" class="error-message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" id="terminatePopupCancelButton">취소</button>
|
||||
<button type="button" class="btn btn-primary" id="terminatePopupConfirmButton">해지 신청</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div th:if="${success}" class="valid-feedback d-block">
|
||||
<span th:text="${message}">인증번호를 발송하였습니다.</span>
|
||||
</div>
|
||||
<div th:if="${!success}" class="invalid-feedback d-block">
|
||||
<div th:unless="${success}" class="invalid-feedback d-block">
|
||||
<span th:text="${message}">인증번호 발송 중 오류가 발생했습니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.eactive.apim.portal.common.exception;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.transaction.TransactionSystemException;
|
||||
import org.springframework.transaction.UnexpectedRollbackException;
|
||||
|
||||
import java.net.SocketTimeoutException;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class UserErrorMessageResolverTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("JTA 롤백 원문은 노출하지 않고 롤백 안내 문구로 치환한다")
|
||||
void jtaRollbackIsReplaced() {
|
||||
// 실제로 팝업에 노출됐던 원문
|
||||
Exception ex = new UnexpectedRollbackException(
|
||||
"JTA transaction unexpectedly rolled back (maybe due to a timeout); nested exception is "
|
||||
+ "javax.transaction.RollbackException: Transaction set to rollback only");
|
||||
|
||||
String message = UserErrorMessageResolver.resolve(ex);
|
||||
|
||||
assertTrue(message.contains("변경 내용이 저장되지 않았습니다"), message);
|
||||
assertTrue(!message.contains("JTA") && !message.contains("Rollback"), message);
|
||||
// "maybe due to a timeout" 이 붙어 있어도 rollback only 면 타임아웃 문구를 쓰지 않는다
|
||||
assertTrue(!message.contains("시간이 초과"), message);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("실제 타임아웃으로 롤백된 경우 타임아웃 안내를 준다")
|
||||
void transactionTimeoutIsReported() {
|
||||
Exception ex = new TransactionSystemException("Could not commit JTA transaction",
|
||||
new SocketTimeoutException("Read timed out"));
|
||||
|
||||
assertTrue(UserErrorMessageResolver.resolve(ex).contains("시간이 초과"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("제약 위반은 데이터 충돌 안내로 치환한다 (롤백 예외로 감싸여 있어도)")
|
||||
void constraintViolationIsReported() {
|
||||
Exception ex = new UnexpectedRollbackException("JTA transaction unexpectedly rolled back",
|
||||
new DataIntegrityViolationException("ORA-00001: unique constraint violated"));
|
||||
|
||||
assertTrue(UserErrorMessageResolver.resolve(ex).contains("충돌"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("서비스가 던진 한글 안내문은 그대로 전달한다")
|
||||
void koreanBusinessMessageIsKept() {
|
||||
String original = "이미 초대가 진행 중인 휴대폰 번호입니다.";
|
||||
|
||||
assertEquals(original, UserErrorMessageResolver.resolve(new IllegalStateException(original)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("영문/기술 메시지와 메시지 없는 예외는 기본 안내로 대체한다")
|
||||
void technicalMessageIsMasked() {
|
||||
assertEquals(UserErrorMessageResolver.DEFAULT_MESSAGE,
|
||||
UserErrorMessageResolver.resolve(new NullPointerException()));
|
||||
assertEquals(UserErrorMessageResolver.DEFAULT_MESSAGE,
|
||||
UserErrorMessageResolver.resolve(new IllegalStateException("Invitation is not in a cancelable state")));
|
||||
assertEquals(UserErrorMessageResolver.DEFAULT_MESSAGE, UserErrorMessageResolver.resolve(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("팝업용 변환은 줄바꿈을 <br> 로 바꾼다")
|
||||
void htmlConversionUsesBrTag() {
|
||||
String html = UserErrorMessageResolver.resolveAsHtml(new NullPointerException());
|
||||
|
||||
assertTrue(html.contains("<br>"), html);
|
||||
assertTrue(!html.contains("\n"), html);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user