API 해지 팝업 추가 및 사용자 오류 메시지 처리 개선

- API 해지 신청 팝업 UI 및 로직 구현
- 사용자 친화적 예외 처리 헬퍼(UserErrorMessageResolver) 추가
- Webhook 신청/수정, 글로벌 예외 처리에 사용자 메시지 연동
This commit is contained in:
Rinjae
2026-07-30 16:22:45 +09:00
parent 829630ae5c
commit 6c231c4189
19 changed files with 696 additions and 139 deletions
@@ -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.ApiKeyRegistrationDTO;
import com.eactive.apim.portal.apps.app.dto.AppRequestDTO; 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.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.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.auth.twofactor.StepUpProtectedPaths; 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.TwoFactorProperties;
import com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService; 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.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.ApiServiceHelper; import com.eactive.apim.portal.common.util.ApiServiceHelper;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -84,7 +84,6 @@ public class MyAppController {
private final ApiService apiService; private final ApiService apiService;
private final ApiServiceHelper apiServiceHelper; private final ApiServiceHelper apiServiceHelper;
private final FileTypeDetector fileTypeDetector; private final FileTypeDetector fileTypeDetector;
private final AdminGatewayClient adminGatewayClient;
private final TwoFactorService twoFactorService; private final TwoFactorService twoFactorService;
private final TwoFactorProperties twoFactorProperties; private final TwoFactorProperties twoFactorProperties;
@@ -184,6 +183,7 @@ public class MyAppController {
model.addAttribute("apiKey", apiKey); model.addAttribute("apiKey", apiKey);
model.addAttribute("secretAvailable", secretAvailable); model.addAttribute("secretAvailable", secretAvailable);
model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id));
return new ModelAndView(CREDENTIAL_DETAIL); return new ModelAndView(CREDENTIAL_DETAIL);
} }
@@ -239,10 +239,12 @@ public class MyAppController {
} }
/** /**
* API Key를 삭제합니다. * API 이용 해지를 신청합니다. (AppRequestType.DELETE 결재 신청 생성)
* AJAX 요청을 지원하기 위해 @ResponseBody를 사용하여 JSON 응답 반환 * 즉시 차단/삭제하지 않으며, eapim-admin 관리자 승인 시점에 GW 차단/삭제와
* PTL_CREDENTIAL 삭제가 실행됩니다. 승인 전까지 API는 정상 동작합니다.
* 본인 확인은 step-up 2FA({@link StepUpProtectedPaths#APP_KEY_DELETE} 인터셉터 가드)가 담당합니다.
* *
* @param requestData 요청 데이터 (clientId와 type 포함) * @param requestData 요청 데이터 (clientId, reason)
* @return 성공/실패 결과를 담은 Map * @return 성공/실패 결과를 담은 Map
*/ */
@PostMapping("/api_key_delete") @PostMapping("/api_key_delete")
@@ -258,38 +260,44 @@ public class MyAppController {
return result; 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(); PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
String orgId = user.getPortalOrg().getId(); String orgId = user.getPortalOrg().getId();
// 1. 소유권 확인 (다른 조직의 인증키 차단/삭제 방지) // 1. 소유권 확인 (다른 조직의 인증키 해지 방지)
if (appServiceFacade.getApiKey(orgId, clientId) == null) { if (appServiceFacade.getApiKey(orgId, clientId) == null) {
result.put("success", false); result.put("success", false);
result.put("msg", "해당 인증키를 찾을 수 없습니다."); result.put("msg", "해당 인증키를 찾을 수 없습니다.");
return result; return result;
} }
// 2. GW 차단(appstatus=0)+리로드를 admin 에 위임. 실패하면 포털 레코드를 삭제하지 않는다. // 2. 해지 신청 생성 + 결재 개시 (GW/credential 은 승인 시점에 admin 이 처리)
try { try {
adminGatewayClient.blockClient(clientId); appServiceFacade.createDeleteRequest(clientId, reason.trim(), user.getPortalOrg());
} catch (Exception e) { } catch (IllegalStateException e) {
log.error("GW 차단/리로드 실패로 인증키 삭제 중단 - clientId={}", clientId, e);
result.put("success", false); result.put("success", false);
result.put("msg", "게이트웨이 차단 처리에 실패하여 삭제를 중단했습니다. 잠시 후 다시 시도해 주세요."); result.put("msg", e.getMessage());
return result; return result;
}
// 3. GW 차단 성공 시에만 포털 credential 삭제
try {
appServiceFacade.deleteApp(orgId, clientId);
} catch (Exception e) { } catch (Exception e) {
log.error("포털 credential 삭제 실패 - clientId={}", clientId, e); log.error("API 이용 해지 신청 실패 - clientId={}", clientId, e);
result.put("success", false); result.put("success", false);
result.put("msg", "삭제 요청 중 오류가 발생했습니다: " + e.getMessage()); result.put("msg", UserErrorMessageResolver.resolveAsHtml(e));
return result; return result;
} }
result.put("success", true); result.put("success", true);
result.put("msg", "API Key가 삭제되었습니다."); result.put("msg", "해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제니다.");
return result; return result;
} }
@@ -31,6 +31,7 @@ import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import java.io.IOException; import java.io.IOException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Arrays; import java.util.Arrays;
import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -64,7 +65,11 @@ public class AppServiceFacade {
public List<ClientDTO> getApikeyList(PortalOrg portalOrg) { public List<ClientDTO> getApikeyList(PortalOrg portalOrg) {
List<Credential> clients = credentialRepository.findAllByOrgid(portalOrg.getId()); 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) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능) // 승인정보(approval) 없는 신청도 목록에 노출`한다. (사용자가 직접 삭제 가능)
appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types)); appRequests.addAll(appRequestRepository .findAllByOrgAndTypeIsInAndApprovalIsNull(portalOrg, types));
// 진행중(PROCESSING) → 요청됨(REQUESTED) → 승인정보 없음 순, 같은 상태끼리는 최근 신청 순
appRequests.sort(Comparator.comparingInt(this::pendingStatusRank)
.thenComparing(AppRequest::getCreatedDate, Comparator.nullsLast(Comparator.reverseOrder())));
return appRequests; 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) { public ClientDTO getApiKey(String orgid, String clientId) {
return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null); return credentialRepository.findByClientidAndOrgid(clientId, orgid).map(credentialMapper::toVo).orElse(null);
} }
@@ -138,13 +160,70 @@ public class AppServiceFacade {
approvalService.beginApproval(approvalId); 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) { public void cancelApiRequest(String id, PortalOrg portalOrg) {
appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> { appRequestRepository.findByIdAndOrg(id, portalOrg).ifPresent(request -> {
if (request.getApproval() == null) { if (request.getApproval() == null) {
// 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제. // 승인정보 없는 신청은 결재 워크플로우가 없으므로 즉시 삭제.
// 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고 // 단, GW에 클라이언트가 존재할 수 있으므로 차단(appstatus=0)+리로드를 먼저 수행하고
// 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서) // 실패 시 삭제를 중단한다. (/api_key_delete 와 동일한 순서)
if (StringUtils.isNotBlank(request.getClientId())) { // DELETE(해지) 신청은 살아있는 클라이언트가 대상이므로 취소 시 GW 를 건드리면 안 된다.
if (StringUtils.isNotBlank(request.getClientId())
&& !AppRequestType.DELETE.equals(request.getType())) {
try { try {
adminGatewayClient.blockClient(request.getClientId()); adminGatewayClient.blockClient(request.getClientId());
} catch (Exception e) { } catch (Exception e) {
@@ -343,18 +422,4 @@ public class AppServiceFacade {
return secret; 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);
}
} }
@@ -2,8 +2,6 @@ package com.eactive.apim.portal.common.exception;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest; 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.config.PortalProperties;
import com.eactive.apim.portal.file.exception.InvalidFileException; import com.eactive.apim.portal.file.exception.InvalidFileException;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.web.multipart.MaxUploadSizeExceededException; import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.core.annotation.Order; 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.security.access.AccessDeniedException;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -39,6 +38,7 @@ public class PortalGlobalExceptionHandler {
private final Logger log = LoggerFactory.getLogger(getClass()); private final Logger log = LoggerFactory.getLogger(getClass());
private final PortalProperties portalProperties; private final PortalProperties portalProperties;
private final Environment environment;
@ExceptionHandler(value = NotFoundException.class) @ExceptionHandler(value = NotFoundException.class)
public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) { public ModelAndView handleINotFoundException(HttpServletRequest request, NotFoundException ex) {
@@ -129,10 +129,10 @@ public class PortalGlobalExceptionHandler {
@ExceptionHandler(value = {IllegalArgumentException.class}) @ExceptionHandler(value = {IllegalArgumentException.class})
public ModelAndView handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException ex) { public ModelAndView handleIllegalArgumentException(HttpServletRequest request, IllegalArgumentException ex) {
log.error(ex.getMessage()); log.error("잘못된 요청 - uri={}, message={}", request.getRequestURI(), ex.getMessage());
ModelAndView modelAndView = new ModelAndView(); ModelAndView modelAndView = new ModelAndView("error");
modelAndView.addObject("errorMessage", ex.getMessage()); modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다.");
modelAndView.setViewName("error"); modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex));
return modelAndView; return modelAndView;
} }
@@ -146,34 +146,40 @@ public class PortalGlobalExceptionHandler {
@ExceptionHandler(value = HttpMediaTypeNotSupportedException.class) @ExceptionHandler(value = HttpMediaTypeNotSupportedException.class)
public ModelAndView handleHttpMediaTypeNotSupportedException(HttpServletRequest request, HttpMediaTypeNotSupportedException ex) { public ModelAndView handleHttpMediaTypeNotSupportedException(HttpServletRequest request, HttpMediaTypeNotSupportedException ex) {
log.error(ex.getMessage()); log.error("지원하지 않는 요청 형식 - uri={}, message={}", request.getRequestURI(), ex.getMessage());
ModelAndView modelAndView = new ModelAndView(); ModelAndView modelAndView = new ModelAndView("error");
modelAndView.addObject("errorMessage", ex.getMessage()); modelAndView.addObject("errorTitle", "요청을 처리할 수 없습니다.");
modelAndView.setViewName("error"); modelAndView.addObject("errorDescription", "지원하지 않는 요청 형식입니다.\n잠시 후 다시 시도해 주세요.");
return modelAndView; return modelAndView;
} }
/**
* 처리되지 않은 예외. 예외 원문은 로그에만 남기고 화면에는 사용자 안내 문구를 표시한다.
* 원문(클래스명/메시지)은 운영(prod) 이외 환경의 상세 영역에만 노출한다.
*/
@ExceptionHandler(value = Exception.class) @ExceptionHandler(value = Exception.class)
public ModelAndView handleException(HttpServletRequest request, Exception ex) { public ModelAndView handleException(HttpServletRequest request, Exception ex) {
Map<String, Object> params = new HashMap<>(2); String requestParams = request.getParameterMap().entrySet()
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()
.stream() .stream()
.map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue())) .map(entry -> entry.getKey() + "=" + Arrays.toString(entry.getValue()))
.collect(Collectors.joining(", ")); .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("error");
ModelAndView modelAndView = new ModelAndView(); modelAndView.addObject("errorTitle", "서비스 처리 중 오류가 발생했습니다.");
modelAndView.addObject("errorMessage", ex.getMessage()); modelAndView.addObject("errorDescription", UserErrorMessageResolver.resolve(ex));
modelAndView.setViewName("error"); if (!isProd()) {
modelAndView.addObject("errorMessage", ex.getClass().getName() + ": " + ex.getMessage());
modelAndView.addObject("activeProfile", String.join(", ", environment.getActiveProfiles()));
}
return modelAndView; return modelAndView;
} }
private boolean isProd() {
return environment.acceptsProfiles(Profiles.of("prod"));
}
@ExceptionHandler(value = InvalidFileException.class) @ExceptionHandler(value = InvalidFileException.class)
public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) { public ModelAndView handleInvalidFileException(HttpServletRequest request, RedirectAttributes redirectAttributes, InvalidFileException ex) {
ModelAndView modelAndView = new ModelAndView(); ModelAndView modelAndView = new ModelAndView();
@@ -53,10 +53,14 @@ public class PortalRestExceptionHandler {
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
} }
/**
* 처리되지 않은 예외. 원문 메시지는 로그에만 남기고, 화면에는 사용자 안내 문구를 내려준다.
* (JTA 롤백/DB 락 같은 인프라 예외의 영문 원문이 팝업에 그대로 노출되지 않도록)
*/
@ExceptionHandler(value = Exception.class) @ExceptionHandler(value = Exception.class)
public ResponseEntity<ResponseDTO> handleUnknownxception(HttpServletRequest request, Exception ex) { public ResponseEntity<ResponseDTO> handleUnknownxception(HttpServletRequest request, Exception ex) {
ex.printStackTrace(); log.error("처리되지 않은 예외 - uri={}", request.getRequestURI(), ex);
ResponseDTO response = new ResponseDTO(500, "500", ex.getMessage()); ResponseDTO response = new ResponseDTO(500, "500", UserErrorMessageResolver.resolveAsHtml(ex));
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); 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.apiservice.dto.ApiGroupSearch;
import com.eactive.apim.portal.apps.app.service.AppServiceFacade; import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService; 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.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil; import com.eactive.apim.portal.common.util.SecurityUtil;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO; import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
@@ -163,8 +164,8 @@ public class WebhookController {
redirectAttributes.addFlashAttribute("registrationSuccess", true); redirectAttributes.addFlashAttribute("registrationSuccess", true);
return new ModelAndView("redirect:/webhook/register/step3"); return new ModelAndView("redirect:/webhook/register/step3");
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage()); log.warn("Webhook 신청 실패 orgId={}", currentOrgId(), e);
redirectAttributes.addFlashAttribute("error", e.getMessage()); redirectAttributes.addFlashAttribute("error", UserErrorMessageResolver.resolve(e));
return new ModelAndView("redirect:/webhook/register/step2"); return new ModelAndView("redirect:/webhook/register/step2");
} }
} }
@@ -270,8 +271,8 @@ public class WebhookController {
redirectAttributes.addFlashAttribute("modifySuccess", true); redirectAttributes.addFlashAttribute("modifySuccess", true);
return new ModelAndView("redirect:/webhook/modify/step3"); return new ModelAndView("redirect:/webhook/modify/step3");
} catch (RuntimeException e) { } catch (RuntimeException e) {
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage()); log.warn("Webhook 수정 실패 orgId={}", currentOrgId(), e);
redirectAttributes.addFlashAttribute("error", e.getMessage()); redirectAttributes.addFlashAttribute("error", UserErrorMessageResolver.resolve(e));
return new ModelAndView("redirect:/webhook/modify/step2"); return new ModelAndView("redirect:/webhook/modify/step2");
} }
} }
@@ -246,6 +246,110 @@ const customPopups = {
$('#passwordPopupError').removeClass('show'); $('#passwordPopupError').removeClass('show');
$('#passwordPopupInput').removeClass('error'); $('#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 - 확인 메시지 * @param {string} message - 확인 메시지
@@ -49,7 +49,7 @@
<!-- Form Content --> <!-- Form Content -->
<form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}" <form name="inquiryForm" id="inquiryForm" th:action="${isNew}? @{/inquiry} : @{/inquiry/edit}"
th:object="${inquiry}" method="post" enctype="multipart/form-data" class="djb-board-form"> 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 --> <!-- Subject Field -->
<div class="form-group"> <div class="form-group">
@@ -85,7 +85,7 @@
<input type="file" id="inquiryImage" name="image" class="djb-input" <input type="file" id="inquiryImage" name="image" class="djb-input"
accept="image/png,image/jpeg,image/gif"> accept="image/png,image/jpeg,image/gif">
<small class="form-help-text">jpg, jpeg, png, gif 이미지 1개만 첨부할 수 있습니다.</small> <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> class="form-help-text">현재 첨부된 이미지가 있습니다. 새 파일을 선택하면 교체됩니다.</small>
</div> </div>
@@ -206,7 +206,7 @@
const file = fileInput.files[0]; const file = fileInput.files[0];
if (file) { if (file) {
/*[# th:if="${!isInternalUser}"]*/ /*[# th:unless="${isInternalUser}"]*/
const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png']; const allowedExtensions = ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.hwp', '.gif', '.jpg', '.jpeg', '.png'];
const fileExt = '.' + file.name.split('.').pop().toLowerCase(); const fileExt = '.' + file.name.split('.').pop().toLowerCase();
@@ -32,57 +32,7 @@
<!-- App List Container --> <!-- App List Container -->
<div class="app-list-container-figma"> <div class="app-list-container-figma">
<!-- App Requests (Pending) --> <!-- API Keys (Approved/Inactive) — 승인완료 우선 표시 -->
<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) -->
<th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}"> <th:block th:if="${apiKeys != null and !apiKeys.isEmpty()}">
<a class="app-card-figma" th:each="apikey : ${apiKeys}" <a class="app-card-figma" th:each="apikey : ${apiKeys}"
th:href="@{/clients/credential_detail(id=${apikey.clientid})}"> th:href="@{/clients/credential_detail(id=${apikey.clientid})}">
@@ -119,6 +69,59 @@
</a> </a>
</th:block> </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 --> <!-- Empty State -->
<div class="app-list-empty-figma" <div class="app-list-empty-figma"
th:if="${(appRequests == null or appRequests.isEmpty()) and (apiKeys == null or apiKeys.isEmpty())}"> 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;"> <div class="dt-actions" style="justify-content: flex-end; gap: 11px; margin-top: 30px;">
<a th:href="@{/clients}" class="dt-btn-gray">목록</a> <a th:href="@{/clients}" class="dt-btn-gray">목록</a>
<button type="button" sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-red" <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> th:data-client-id="${apiKey.clientid}" onclick="deleteApiKeyFromButton(this)"
<a sec:authorize="hasRole('ROLE_API_KEY_REQUEST')" class="dt-btn-blue" 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> th:href="@{/clients/modify/step1(clientId=${apiKey.clientid})}">변경 신청</a>
</div> </div>
@@ -295,44 +297,44 @@
document.body.removeChild(textArea); document.body.removeChild(textArea);
} }
// Delete API Key function - called from button with data-client-id attribute // API 이용 해지 신청 진입 - 경고 + 사유 모달 (본인 확인은 step-up 2FA가 담당)
function deleteApiKeyFromButton(button) { function deleteApiKeyFromButton(button) {
var clientId = $(button).data('client-id'); var clientId = $(button).data('client-id');
customPopups.showConfirm('정말로 이 인증키를 삭제하시겠습니까?', function (confirmed) { customPopups.showTerminateRequest({
if (!confirmed) { onConfirm: function (reason) {
return; doDeleteApiKey(clientId, reason);
} }
doDeleteApiKey(clientId);
}); });
} }
// 인증키 삭제 요청 (step-up 필요 시 2FA 후 동일 요청 재시도) // 해지 신청 요청 (step-up 필요 시 2FA 후 동일 요청 재시도)
function doDeleteApiKey(clientId) { function doDeleteApiKey(clientId, reason) {
$('.loading-overlay').show(); $('.loading-overlay').show();
$.ajax({ $.ajax({
url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete', url: /*[[@{/clients/api_key_delete}]]*/ '/clients/api_key_delete',
type: 'POST', type: 'POST',
contentType: 'application/json', contentType: 'application/json',
data: JSON.stringify({ clientId: clientId }), data: JSON.stringify({ clientId: clientId, reason: reason }),
headers: { headers: {
'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token' 'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token'
} }
}).done(function (response) { }).done(function (response) {
if (response && response.success === false) { if (response && response.success === false) {
customPopups.showAlert(response.msg || 'API 삭제에 실패했습니다.'); customPopups.showTerminateError(response.msg || '해지 신청에 실패했습니다.');
return; return;
} }
customPopups.showAlert(response.msg || 'API Key가 삭제되었습니다.', function () { customPopups.hideTerminateRequest();
customPopups.showAlert(response.msg || '해지 신청이 접수되었습니다. 관리자 승인 후 인증키가 삭제됩니다.', function () {
window.location.href = /*[[@{/clients}]]*/ '/clients'; window.location.href = /*[[@{/clients}]]*/ '/clients';
}); });
}).fail(function (jqXHR, textStatus, errorThrown) { }).fail(function (jqXHR, textStatus, errorThrown) {
if (isStepUpRequired(jqXHR)) { if (isStepUpRequired(jqXHR)) {
requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId); }); requireStepUp('/clients/api_key_delete', function () { doDeleteApiKey(clientId, reason); });
return; return;
} }
customPopups.showAlert('API 삭제 요청 중 오류가 발생했습니다: ' + errorThrown); customPopups.showTerminateError('해지 신청 요청 중 오류가 발생했습니다: ' + errorThrown);
}).always(function () { }).always(function () {
$('.loading-overlay').hide(); $('.loading-overlay').hide();
}); });
@@ -6,7 +6,7 @@
<section layout:fragment="title"> <section layout:fragment="title">
<div class="page-title-banner"> <div class="page-title-banner">
<img th:src="@{/img/img_title_bg.png}" alt="개인회원가입" class="title-image"> <img th:src="@{/img/img_title_bg.png}" alt="개인회원가입" class="title-image">
<h1 th:text="${!isInvited ? '개인회원가입' : '법인회원가입'}">개인회원가입</h1> <h1 th:text="${isInvited != true ? '개인회원가입' : '법인회원가입'}">개인회원가입</h1>
</div> </div>
</section> </section>
@@ -23,7 +23,7 @@
<!-- Registration Card Wrapper --> <!-- Registration Card Wrapper -->
<div class="register-card-wrapper"> <div class="register-card-wrapper">
<!-- Info Notice --> <!-- Info Notice -->
<div class="org-info-notice" th:if="${!isInvited}"> <div class="org-info-notice" th:unless="${isInvited}">
<div class="notice-icon-wrapper"> <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"> <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" /> <circle cx="12" cy="12" r="10" />
@@ -1,5 +1,5 @@
<div th:fragment="authNumberValidation"> <div th:fragment="authNumberValidation">
<div th:if="${!isValid}" class="invalid-feedback d-block"> <div th:unless="${isValid}" class="invalid-feedback d-block">
올바른 인증번호를 입력해주세요. 올바른 인증번호를 입력해주세요.
</div> </div>
<div th:if="${isValid}" class="valid-feedback d-block"> <div th:if="${isValid}" class="valid-feedback d-block">
@@ -1,6 +1,6 @@
<div th:fragment="result"> <div th:fragment="result">
<span id="validationResult" <span id="validationResult"
th:text="${!isValid} ? '올바른 법인 등록번호 형식이 아닙니다. 앞 6자리, 뒤 7자리의 숫자를 입력해주세요.' : '유효한 법인 등록번호입니다.'" th:text="${isValid != true} ? '올바른 법인 등록번호 형식이 아닙니다. 앞 6자리, 뒤 7자리의 숫자를 입력해주세요.' : '유효한 법인 등록번호입니다.'"
th:class="${!isValid} ? 'invalid-feedback d-block' : 'valid-feedback d-block'"> th:class="${isValid != true} ? 'invalid-feedback d-block' : 'valid-feedback d-block'">
</span> </span>
</div> </div>
@@ -1,5 +1,5 @@
<div th:fragment="result"> <div th:fragment="result">
<div th:if="${!isValidFormat}" class="invalid-feedback d-block"> <div th:unless="${isValidFormat}" class="invalid-feedback d-block">
올바른 이메일 형식이 아닙니다. 올바른 이메일 형식이 아닙니다.
</div> </div>
<div th:if="${isValidFormat}"> <div th:if="${isValidFormat}">
@@ -11,6 +11,6 @@
</div> </div>
</div> </div>
<span id="validationResult" style="display:none;" <span id="validationResult" style="display:none;"
th:text="${!isValidFormat} ? '올바른 이메일 형식이 아닙니다.' : (${isDuplicate} ? '이미 등록된 이메일입니다.' : '사용 가능한 이메일입니다.')"> th:text="${isValidFormat != true} ? '올바른 이메일 형식이 아닙니다.' : (${isDuplicate} ? '이미 등록된 이메일입니다.' : '사용 가능한 이메일입니다.')">
</span> </span>
</div> </div>
@@ -4,4 +4,5 @@
<div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div> <div th:replace="fragment/popup/emailValidationPopup :: #emailValidationPopup"></div>
<div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div> <div th:replace="fragment/popup/customPopup2 :: #customConfirm"></div>
<div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div> <div th:replace="fragment/popup/passwordInputPopup :: passwordInputPopup"></div>
<div th:replace="fragment/popup/terminateRequestPopup :: terminateRequestPopup"></div>
</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"> <div th:if="${success}" class="valid-feedback d-block">
<span th:text="${message}">인증번호를 발송하였습니다.</span> <span th:text="${message}">인증번호를 발송하였습니다.</span>
</div> </div>
<div th:if="${!success}" class="invalid-feedback d-block"> <div th:unless="${success}" class="invalid-feedback d-block">
<span th:text="${message}">인증번호 발송 중 오류가 발생했습니다.</span> <span th:text="${message}">인증번호 발송 중 오류가 발생했습니다.</span>
</div> </div>
</div> </div>
@@ -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);
}
}