From b0ecb26a9d2363b8221d67af63001e8277548f73 Mon Sep 17 00:00:00 2001 From: Rinjae Date: Tue, 11 Aug 2026 14:15:33 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=80=EA=B2=BD/=ED=95=B4=EC=A7=80=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=82=AC?= =?UTF-8?q?=EC=A0=84=EA=B2=80=EC=82=AC=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD/=ED=95=B4=EC=A7=80=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20=EC=B7=A8=EC=86=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20-=20=ED=95=B4=EC=A7=80=20?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=82=AC=EC=A0=84=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B2=BD=EA=B3=A0=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84=20-=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=8A=B9=EC=9D=B8=20=EC=9D=B4=EC=A0=84=20?= =?UTF-8?q?=ED=97=88=EC=9A=A9=EB=90=98=EB=8A=94=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=EC=A0=95=EC=B1=85=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 11 ++- .../apps/app/controller/MyAppController.java | 58 +++++++++++- .../apps/app/service/AppServiceFacade.java | 71 +++++++++++--- .../views/apps/mypage/apiKeyList.html | 7 +- .../views/apps/mypage/appRequestDetail.html | 7 +- .../views/apps/mypage/credentialDetail.html | 92 +++++++++++++++++-- 6 files changed, 218 insertions(+), 28 deletions(-) diff --git a/build.gradle b/build.gradle index 80895e4..9f0d744 100644 --- a/build.gradle +++ b/build.gradle @@ -129,7 +129,16 @@ bootRun { // jvmArgs '-Xdebug', '-Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=5005' sourceResources sourceSets.main // processResources 필터 우회 (dev yml 직접 참조) - args = ["--spring.profiles.active=dev"] + + // 로컬 기동 기본 프로파일. + // dev/stage/prod 는 리버스프록시(OHS) 뒤 WAR 배포용이라 server.forward-headers-strategy=framework 가 + // 걸려 있다. framework 는 신뢰 프록시 목록 없이 X-Forwarded-* 를 그대로 신뢰하므로 앞단이 없는 + // 로컬 기동에는 쓰지 않는다(로컬은 공통 기본값 native = Tomcat RemoteIpValve, 사설대역만 신뢰). + // 다른 프로파일로 띄우려면: gradle bootRun -PbootProfile=dev + // ('profile' 이 아니라 'bootProfile' 인 이유: 위 ext 블록이 profile='local' 을 이미 점유하고 있어 + // findProperty('profile') 은 -P 지정 여부와 무관하게 항상 'local' 을 돌려준다.) + def bootRunProfile = (project.findProperty('bootProfile') ?: 'local_rinjaemac').toString() + args = ["--spring.profiles.active=" + bootRunProfile] } diff --git a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java index 873c07c..81aebe4 100644 --- a/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java +++ b/src/main/java/com/eactive/apim/portal/apps/app/controller/MyAppController.java @@ -23,6 +23,8 @@ import com.eactive.apim.portal.file.service.FileTypeDetector; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import javax.servlet.http.HttpSession; import javax.validation.Valid; import lombok.RequiredArgsConstructor; @@ -102,8 +104,28 @@ public class MyAppController { List appRequests = appServiceFacade.getPendingApiKeyList(user.getPortalOrg()); List apikeys = appServiceFacade.getApikeyList(user.getPortalOrg()); - model.addAttribute("appRequests", appRequests); + // 기존 클라이언트에 걸린 변경/해지 신청은 별도 카드로 내지 않고 해당 클라이언트 카드의 배지로 흡수한다. + // (같은 이름의 카드가 둘로 보여 클라이언트가 두 개인 것처럼 읽히기 때문) + // 신규(NEW) 신청처럼 아직 클라이언트가 없는 건만 신청 카드로 남긴다. + Set ownedClientIds = apikeys.stream() + .map(ClientDTO::getClientid) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toSet()); + + Map openRequestByClient = new java.util.HashMap<>(); + List standaloneRequests = new java.util.ArrayList<>(); + for (AppRequest request : appRequests) { + String clientId = request.getClientId(); + if (StringUtils.isNotBlank(clientId) && ownedClientIds.contains(clientId) + && openRequestByClient.putIfAbsent(clientId, request) == null) { + continue; + } + standaloneRequests.add(request); + } + + model.addAttribute("appRequests", standaloneRequests); model.addAttribute("apiKeys", apikeys); + model.addAttribute("openRequestByClient", openRequestByClient); return new ModelAndView(API_KEY_LIST); } @@ -184,6 +206,10 @@ public class MyAppController { model.addAttribute("apiKey", apiKey); model.addAttribute("secretAvailable", secretAvailable); model.addAttribute("pendingDeleteRequest", appServiceFacade.hasPendingDeleteRequest(id)); + // 해지 불가 사유(변경 신청 진행 중 등) — 클릭 시 사전검사가 이 사유를 안내하고 2FA 로 넘어가지 않는다 + model.addAttribute("deleteBlockReason", appServiceFacade.resolveDeleteBlockReason(id)); + // 걸려 있는 미완료 변경/해지 신청 — 상태 표시 + 취소 버튼용(결재 미개시 건도 포함) + model.addAttribute("openRequest", appServiceFacade.findOpenRequest(id)); return new ModelAndView(CREDENTIAL_DETAIL); } @@ -247,6 +273,36 @@ public class MyAppController { * @param requestData 요청 데이터 (clientId, reason) * @return 성공/실패 결과를 담은 Map */ + /** + * 해지 신청 사전검사. 진행 중인 변경/해지 결재가 있어 신청이 불가한지 알려줍니다. + * + *

step-up 2FA 가 걸린 {@code /clients/api_key_delete} 이전에 호출해, + * 어차피 거절될 요청으로 2FA 를 반복 유도하지 않도록 한다(2FA 가드 대상 경로가 아니다). + * 최종 판정은 {@code createDeleteRequest} 가 다시 수행하므로 이 검사는 안내 목적이다.

+ */ + @GetMapping("/credential/delete-precheck") + @Secured("ROLE_API_KEY_REQUEST") + @ResponseBody + public Map deleteApiKeyPrecheck(@RequestParam("clientId") String clientId) { + Map result = new java.util.HashMap<>(); + + PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser(); + String orgId = user.getPortalOrg().getId(); + + // 소유권 확인 (다른 조직 인증키의 결재 진행 상태 노출 방지) + if (clientId == null || clientId.trim().isEmpty() + || appServiceFacade.getApiKey(orgId, clientId) == null) { + result.put("blocked", true); + result.put("msg", "해당 인증키를 찾을 수 없습니다."); + return result; + } + + String blockReason = appServiceFacade.resolveDeleteBlockReason(clientId); + result.put("blocked", blockReason != null); + result.put("msg", blockReason); + return result; + } + @PostMapping("/api_key_delete") @Secured("ROLE_API_KEY_REQUEST") @ResponseBody diff --git a/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java b/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java index c480f72..a2e1576 100644 --- a/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java +++ b/src/main/java/com/eactive/apim/portal/apps/app/service/AppServiceFacade.java @@ -7,6 +7,7 @@ import com.eactive.apim.portal.apprequest.entity.AppRequest; import com.eactive.apim.portal.apprequest.entity.AppRequestType; import com.eactive.apim.portal.apprequest.repository.AppRequestRepository; import com.eactive.apim.portal.approval.entity.Approval; +import com.eactive.apim.portal.approval.statemachine.CreatedState; import com.eactive.apim.portal.approval.statemachine.ProcessingState; import com.eactive.apim.portal.approval.statemachine.RequestedState; import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto; @@ -169,21 +170,9 @@ public class AppServiceFacade { */ public void createDeleteRequest(String clientId, String reason, PortalOrg portalOrg) { // 1. 진행 중(REQUESTED/PROCESSING)인 해지·변경 신청 중복 가드 - List 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("해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요."); + String blockReason = resolveDeleteBlockReason(clientId); + if (blockReason != null) { + throw new IllegalStateException(blockReason); } // 2. DELETE 신청 생성 (createAppRequest 의 DELETE 분기가 clientName/prevApiList/apiList 를 채운다) @@ -203,6 +192,58 @@ public class AppServiceFacade { beginApproval(saved.getApproval().getId()); } + /** + * 해지 신청을 막는 사유가 있으면 안내 메시지를, 없으면 {@code null} 을 반환합니다. + * + *

미완료 해지·변경 신청이 있으면 해지를 받을 수 없다. {@link #createDeleteRequest} 의 최종 가드와 + * 화면/사전검사가 같은 판정({@link #findOpenRequestEntity})을 쓰도록 이 메서드 하나로 모은다. + * (사전검사가 없으면 사용자가 step-up 2FA 를 통과한 뒤에야 차단 사실을 알게 되고, + * 재시도할 때마다 2FA 가 반복된다.)

+ */ + public String resolveDeleteBlockReason(String clientId) { + AppRequest open = findOpenRequestEntity(clientId); + if (open == null) { + return null; + } + if (AppRequestType.DELETE.equals(open.getType())) { + return "이미 해지 신청이 진행 중입니다. 결재 완료 후 다시 확인해 주세요."; + } + return "해당 인증키의 변경 신청이 진행 중이라 해지를 신청할 수 없습니다. 변경 결재 완료 또는 취소 후 다시 시도해 주세요."; + } + + /** + * 해당 인증키에 걸려 있는 미완료 변경/해지 신청 1건. 없으면 {@code null}. + * 상세 화면에서 진행 상태를 표시하고 신청을 취소할 수 있게 하는 데 사용한다. + */ + public AppRequestDTO findOpenRequest(String clientId) { + AppRequest open = findOpenRequestEntity(clientId); + return open == null ? null : appRequestMapper.toVo(open); + } + + /** + * 미완료(=결재가 끝나지 않은) 변경/해지 신청 1건. + * + *

결재가 개시되지 않아 {@code approval} 이 없는 신청도 포함한다. 이런 건은 관리자 결재함에 + * 뜨지 않아 사용자가 상태를 알 수도, 정리할 수도 없는 채로 남아 이후 신청을 계속 막는다. + * 화면에 노출해 취소할 수 있게 하려면 여기서 잡아야 한다.

+ */ + private AppRequest findOpenRequestEntity(String clientId) { + List related = appRequestRepository.findAllByClientIdsContainsAndTypeIsIn( + clientId, Arrays.asList(AppRequestType.MODIFY, AppRequestType.DELETE)); + for (AppRequest r : related) { + if (r.getApproval() == null) { + return r; + } + Object status = r.getApproval().getApprovalStatus(); + if (status instanceof CreatedState + || status instanceof RequestedState + || status instanceof ProcessingState) { + return r; + } + } + return null; + } + /** * 해당 클라이언트의 해지 신청이 결재 진행 중(REQUESTED/PROCESSING)인지 확인합니다. * 상세 화면의 해지 버튼 비활성화에 사용됩니다. diff --git a/src/main/resources/templates/views/apps/mypage/apiKeyList.html b/src/main/resources/templates/views/apps/mypage/apiKeyList.html index 7971f2d..a37d7c2 100644 --- a/src/main/resources/templates/views/apps/mypage/apiKeyList.html +++ b/src/main/resources/templates/views/apps/mypage/apiKeyList.html @@ -51,9 +51,14 @@
-
+

앱 이름

+ + 해지 + 신청 진행중 - +
-

내부 결재가 진행 중이라 신청을 취소할 수 없습니다. 취소가 필요한 경우 관리자에게 문의해 주세요.

+

관리자가 심사 중인 신청입니다. 결재자가 이미 승인한 경우에는 취소되지 않으며, 이때는 관리자에게 문의해 주세요.

@@ -226,7 +226,8 @@ diff --git a/src/main/resources/templates/views/apps/mypage/credentialDetail.html b/src/main/resources/templates/views/apps/mypage/credentialDetail.html index 2899f45..665cfdf 100644 --- a/src/main/resources/templates/views/apps/mypage/credentialDetail.html +++ b/src/main/resources/templates/views/apps/mypage/credentialDetail.html @@ -186,13 +186,32 @@
+ +
+

+ 신청이 + 접수되어 있습니다. + 신청 상세 보기 +

+
+
목록 + + + + th:disabled="${pendingDeleteRequest}" th:title="${deleteBlockReason}" + th:text="${pendingDeleteRequest} ? '해지 승인 대기중' : '이용 해지 신청'">이용 해지 신청 변경 신청
@@ -297,19 +316,74 @@ document.body.removeChild(textArea); } - // API 이용 해지 신청 진입 - 경고 + 사유 모달 (본인 확인은 step-up 2FA가 담당) + // API 이용 해지 신청 진입 - 사전검사(진행 중 결재 확인) 후 경고 + 사유 모달 + // (본인 확인은 step-up 2FA가 담당. 사전검사를 먼저 하는 이유: 어차피 거절될 요청으로 + // 2FA를 통과시킨 뒤 실패시키면, 재시도할 때마다 2FA가 반복된다) function deleteApiKeyFromButton(button) { var clientId = $(button).data('client-id'); - customPopups.showTerminateRequest({ - onConfirm: function (reason) { - doDeleteApiKey(clientId, reason); + $.ajax({ + url: /*[[@{/clients/credential/delete-precheck}]]*/ '/clients/credential/delete-precheck', + type: 'GET', + data: { clientId: clientId } + }).done(function (res) { + if (res && res.blocked) { + customPopups.showAlert(res.msg || '현재 해지를 신청할 수 없습니다.'); + return; } + customPopups.showTerminateRequest({ + onConfirm: function (reason) { + doDeleteApiKey(clientId, reason); + } + }); + }).fail(function () { + customPopups.showAlert('해지 가능 여부 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'); }); } + // 진행 중 변경/해지 신청 취소 — 결재가 개시되지 않아 관리자 결재함에 뜨지 않는 신청도 여기서 정리한다 + function cancelOpenRequest(button) { + var requestId = $(button).data('request-id'); + + customPopups.showConfirm('진행 중인 신청을 취소하시겠습니까?', function (ok) { + if (!ok) { + return; + } + $('.loading-overlay').show(); + $.ajax({ + url: /*[[@{/clients/api_key_request/cancel}]]*/ '/clients/api_key_request/cancel', + type: 'POST', + data: { id: requestId }, + dataType: 'json', + headers: { + 'X-XSRF-TOKEN': /*[[${_csrf.token}]]*/ 'token' + } + }).done(function (response) { + if (response && response.success) { + customPopups.showAlert(response.message || '신청이 취소되었습니다.', function () { + window.location.reload(); + }); + return; + } + customPopups.showAlert((response && response.message) || '신청 취소 중 오류가 발생했습니다.'); + }).fail(function () { + customPopups.showAlert('신청 취소 중 오류가 발생했습니다.'); + }).always(function () { + $('.loading-overlay').hide(); + }); + }); + } + + // 해지 신청 진행 중 플래그 — 중복 클릭 시 두 번째 요청이 통과권(1회용) 없이 나가 + // 2FA가 다시 뜨는 것을 막는다 + var deleteRequestInFlight = false; + // 해지 신청 요청 (step-up 필요 시 2FA 후 동일 요청 재시도) function doDeleteApiKey(clientId, reason) { + if (deleteRequestInFlight) { + return; + } + deleteRequestInFlight = true; $('.loading-overlay').show(); $.ajax({ @@ -322,7 +396,10 @@ } }).done(function (response) { if (response && response.success === false) { - customPopups.showTerminateError(response.msg || '해지 신청에 실패했습니다.'); + // 실패 사유(결재 진행 중·승인라인 미등록 등)는 재시도해도 그대로다. + // 모달을 열어둔 채 인라인 문구만 보여주면 사용자가 다시 확인을 눌러 2FA만 반복되므로 닫고 안내한다. + customPopups.hideTerminateRequest(); + customPopups.showAlert(response.msg || '해지 신청에 실패했습니다.'); return; } customPopups.hideTerminateRequest(); @@ -336,6 +413,7 @@ } customPopups.showTerminateError('해지 신청 요청 중 오류가 발생했습니다: ' + errorThrown); }).always(function () { + deleteRequestInFlight = false; $('.loading-overlay').hide(); }); }