merge 충돌 해결
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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.partnership.mapper.PartnershipAppl
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.UserTypeUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||
import com.eactive.apim.portal.djb.swing.SwingNotifier;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
import com.eactive.apim.portal.file.service.FileTypeContext;
|
||||
@@ -29,7 +29,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
private final PartnershipApplicationMapper partnershipApplicationMapper;
|
||||
private final FileService fileService;
|
||||
// portal-admin 알림 발행기(범용). Q&A 등록 알림과 동일 컴포넌트를 재사용한다.
|
||||
private final CommunityAdminNotifier portalAdminNotifier;
|
||||
private final SwingNotifier swingNotifier;
|
||||
|
||||
|
||||
@Override
|
||||
@@ -61,7 +61,7 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
|
||||
if (writer != null) {
|
||||
params.put("writerName", writer.getUserName());
|
||||
}
|
||||
portalAdminNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||
swingNotifier.notifyPortalAdmins(MessageCode.PARTNERSHIP_CREATED, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+3
-3
@@ -6,7 +6,7 @@ import com.eactive.apim.portal.apps.community.qna.mapper.InquiryMapper;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.service.CommunityAdminNotifier;
|
||||
import com.eactive.apim.portal.djb.swing.SwingNotifier;
|
||||
import com.eactive.apim.portal.file.entity.FileInfo;
|
||||
import com.eactive.apim.portal.file.exception.InvalidFileException;
|
||||
import com.eactive.apim.portal.file.service.FileService;
|
||||
@@ -40,7 +40,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
|
||||
private final InquiryService inquiryService;
|
||||
private final InquiryMapper inquiryMapper;
|
||||
private final CommunityAdminNotifier inquiryAdminNotifier;
|
||||
private final SwingNotifier swingNotifier;
|
||||
private final FileService fileService;
|
||||
|
||||
@Override
|
||||
@@ -142,7 +142,7 @@ public class InquiryFacadeImpl implements InquiryFacade {
|
||||
params.put("inquiryId", inquiry.getId());
|
||||
params.put("inquirySubject", inquiry.getInquirySubject());
|
||||
params.put("writerName", current.getUserName());
|
||||
inquiryAdminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
|
||||
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_CREATED, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -57,6 +57,13 @@ public class AccountController {
|
||||
private final TwoFactorProperties twoFactorProperties;
|
||||
|
||||
|
||||
/** 비밀번호 변경 화면 라이브 체크: 입력 중인 비밀번호에 아이디/휴대전화가 포함되는지 (민감정보는 응답에 미포함) */
|
||||
@PostMapping("/password/content-check")
|
||||
public ResponseEntity<Map<String, Boolean>> checkPasswordContent(@RequestParam String password) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
return ResponseEntity.ok(userFacade.checkPasswordContent(currentLoginId, password));
|
||||
}
|
||||
|
||||
@PostMapping("/password/confirm")
|
||||
public ResponseEntity<ValidationResponse> confirmPassword(@RequestParam String inputPassword) {
|
||||
String currentLoginId = SecurityUtil.getCurrentLoginId();
|
||||
|
||||
@@ -54,6 +54,7 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
private final FileService fileService;
|
||||
private final BasicValidationService validationService;
|
||||
private final UserRegistrationValidationService userRegistrationValidationService;
|
||||
private final com.eactive.apim.portal.apps.user.validator.PasswordValidator passwordValidator;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final AgreementValidator agreementValidator;
|
||||
private final ApprovalService approvalService;
|
||||
@@ -75,14 +76,29 @@ public class OrgRegisterFacadeImpl implements OrgRegisterFacade {
|
||||
return new ValidationResponse(false, "입력 정보가 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
// 2025.10.20 - 휴대폰 번호 중복 무시
|
||||
// PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(orgDTO.getUserName(), orgDTO.getMobileNumber());
|
||||
// 개인 가입(@Valid @PasswordRule)과 달리 법인 가입은 컨트롤러 바인딩 검증이 없어
|
||||
// 여기서 서버 측 비밀번호 규칙을 직접 검증한다 (retain/change 시나리오는 기존 비밀번호 유지라 제외)
|
||||
if (!passwordValidator.isValidPassword(orgDTO.getPassword(), orgDTO.getLoginId(), orgDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false,
|
||||
"비밀번호는 영문/숫자/특수문자 포함 8~50자이며, 아이디·휴대전화 번호, 3자리 이상 연속·반복 문자는 사용할 수 없습니다.");
|
||||
}
|
||||
|
||||
if (orgDTO.getConfirmPassword() == null || !orgDTO.getConfirmPassword().equals(orgDTO.getPassword())) {
|
||||
return new ValidationResponse(false, "비밀번호와 비밀번호 확인이 일치하지 않습니다.");
|
||||
}
|
||||
|
||||
Optional<PortalUser> existingUser = portalUserRepository.findByLoginId(orgDTO.getLoginId());
|
||||
|
||||
if(existingUser.isPresent()) {
|
||||
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
}
|
||||
|
||||
// 휴대폰 번호 중복 검증 (Portal/user.mobile.duplicate.allow 프로퍼티에 따라 차단)
|
||||
if (portalUserService.isMobileDuplicateCheckEnabled()
|
||||
&& portalUserService.existsByMobileNumber(orgDTO.getMobileNumber())) {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
try {
|
||||
FileInfo uploadedFile = handleFileUpload(orgDTO.getFiles());
|
||||
if (uploadedFile == null) {
|
||||
|
||||
@@ -10,6 +10,9 @@ public interface UserFacade {
|
||||
|
||||
void updatePassword(String loginId, String newPassword, String confirmPassword);
|
||||
|
||||
/** 비밀번호에 아이디/휴대전화 번호가 포함되는지 라이브 체크용 판정 (키: idIncluded, mobileIncluded) */
|
||||
java.util.Map<String, Boolean> checkPasswordContent(String loginId, String password);
|
||||
|
||||
void updateUser(PortalUserDTO portalUserDTO);
|
||||
|
||||
void updateCorporateManager(PortalUserDTO portalUserDTO);
|
||||
|
||||
@@ -60,6 +60,12 @@ public class UserFacadeImpl implements UserFacade {
|
||||
messageHandlerService.publishEvent(UserPasswordChangedEvent.KEY, recipient, params);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(readOnly = true)
|
||||
public HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
|
||||
return passwordService.checkPasswordContent(loginId, password);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void updateUser(PortalUserDTO portalUserDTO) {
|
||||
|
||||
@@ -187,7 +187,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
|
||||
return new ValidationResponse(false, "이미 가입된 휴대폰 번호입니다.");
|
||||
}
|
||||
|
||||
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(), registrationDTO.getMobileNumber());
|
||||
PortalUser existingUser = portalUserRepository.findByUserNameAndMobileNumber(registrationDTO.getUserName(),
|
||||
com.eactive.apim.portal.common.util.PhoneNumberUtil.normalize(registrationDTO.getMobileNumber()));
|
||||
|
||||
if(existingUser != null) {
|
||||
return new ValidationResponse(false, "가입된 계정이 이미 존재합니다.");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.eactive.apim.portal.apps.user.service;
|
||||
|
||||
import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
|
||||
import com.eactive.apim.portal.common.validator.PasswordRuleValidator;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
|
||||
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
|
||||
@@ -71,6 +72,19 @@ public class PasswordService {
|
||||
}
|
||||
}
|
||||
|
||||
/** 비밀번호에 본인 아이디(local part)/휴대전화 세그먼트가 포함되는지 판정 — 변경 화면 라이브 체크용 */
|
||||
@Transactional(readOnly = true)
|
||||
public java.util.HashMap<String, Boolean> checkPasswordContent(String loginId, String password) {
|
||||
PortalUser user = portalUserRepository.findByLoginId(loginId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
|
||||
java.util.HashMap<String, Boolean> result = new java.util.HashMap<>();
|
||||
result.put("idIncluded",
|
||||
PasswordRuleValidator.containsLoginIdLocalPart(password, user.getLoginId()));
|
||||
result.put("mobileIncluded",
|
||||
PasswordRuleValidator.containsMobileSegment(password, user.getMobileNumber()));
|
||||
return result;
|
||||
}
|
||||
|
||||
private void checkPasswordHistory(String userId, String newPassword) {
|
||||
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.eactive.apim.portal.apps.login.service.LoginFinalizer;
|
||||
import com.eactive.apim.portal.apps.user.dto.PortalUserDTO;
|
||||
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
|
||||
import com.eactive.apim.portal.common.exception.SystemException;
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.common.util.EncryptionUtil;
|
||||
@@ -151,6 +152,8 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
}
|
||||
|
||||
public void resetPassword(String loginId, String userName, String mobileNumber) {
|
||||
// 입력 그룹핑이 저장 정규형과 달라도 매칭되도록 조회 전 정규화 (암호화 컬럼 등가 비교)
|
||||
mobileNumber = PhoneNumberUtil.normalize(mobileNumber);
|
||||
if (mobileNumber == null || !mobileNumber.matches("^\\d{2,3}-\\d{3,4}-\\d{4}$")) {
|
||||
throw new UserNotFoundException("유효하지 않은 휴대폰 번호 형식입니다.");
|
||||
}
|
||||
@@ -185,7 +188,7 @@ public class PortalUserAuthService implements UserDetailsService {
|
||||
@Transactional
|
||||
public void reactivateDormantAccount(String loginId, String password, String mobileNumber) {
|
||||
try {
|
||||
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, mobileNumber)
|
||||
PortalUser portalUser = portalUserRepository.findByLoginIdAndMobileNumber(loginId, PhoneNumberUtil.normalize(mobileNumber))
|
||||
.orElseThrow(() -> new UserNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요."));
|
||||
|
||||
if (!passwordEncoder.matches(password, portalUser.getPasswordHash())) {
|
||||
|
||||
+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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.eactive.apim.portal.common.security;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 로그인 실패 계정 잠금 임계 횟수를 DB(PortalProperty)에서 조회한다.
|
||||
*
|
||||
* <p>PTL_PROPERTY (group={@code Portal}, name={@code login.failure.lock.count}) 값으로 제어한다.
|
||||
* 값이 없으면 기본값 {@value #DEFAULT_LOCK_COUNT}로 자동 생성되고, 숫자가 아니거나
|
||||
* 0 이하이면 기본값으로 동작한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoginLockPolicy {
|
||||
|
||||
private static final String GROUP = "Portal";
|
||||
private static final String NAME = "login.failure.lock.count";
|
||||
|
||||
/** 기본 잠금 임계 횟수 (프로퍼티 미존재/파싱 실패 시) */
|
||||
public static final int DEFAULT_LOCK_COUNT = 5;
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 연속 로그인 실패가 이 값 이상이면 계정을 잠근다. */
|
||||
public int lockCount() {
|
||||
String raw = portalPropertyService.getOrCreateProperty(
|
||||
GROUP, NAME, String.valueOf(DEFAULT_LOCK_COUNT),
|
||||
"로그인 연속 실패 계정 잠금 임계 횟수 (이 값 이상 실패 시 잠금)");
|
||||
try {
|
||||
int parsed = Integer.parseInt(raw.trim());
|
||||
if (parsed > 0) {
|
||||
return parsed;
|
||||
}
|
||||
log.warn("login.failure.lock.count 값이 0 이하({}) - 기본값 {} 사용", parsed, DEFAULT_LOCK_COUNT);
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn("login.failure.lock.count 값이 숫자가 아님('{}') - 기본값 {} 사용", raw, DEFAULT_LOCK_COUNT);
|
||||
}
|
||||
return DEFAULT_LOCK_COUNT;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.eactive.apim.portal.common.validator;
|
||||
|
||||
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
|
||||
import org.apache.commons.beanutils.PropertyUtils;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
@@ -72,24 +73,12 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
return false;
|
||||
}
|
||||
|
||||
if (loginId != null && !loginId.isEmpty()) {
|
||||
String[] loginParts = loginId.split("@");
|
||||
if (loginParts.length > 0) {
|
||||
String username = loginParts[0].toUpperCase();
|
||||
if (tmpPw.contains(username)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (containsLoginIdLocalPart(password, loginId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Mobile number validation
|
||||
if (mobileNumber != null && !mobileNumber.isEmpty()) {
|
||||
String[] mobileParts = mobileNumber.split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (containsMobileSegment(password, mobileNumber)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 공백 체크
|
||||
@@ -129,6 +118,33 @@ public class PasswordRuleValidator implements ConstraintValidator<PasswordRule,
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 아이디(이메일)의 @ 앞 local part 가 비밀번호에 포함되는지 (대소문자 무시) */
|
||||
public static boolean containsLoginIdLocalPart(String password, String loginId) {
|
||||
if (password == null || loginId == null || loginId.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String username = loginId.split("@")[0].toUpperCase();
|
||||
return !username.isEmpty() && password.toUpperCase().contains(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴대전화 번호의 하이픈 세그먼트(010/1234/5678)가 비밀번호에 포함되는지.
|
||||
* DB 에 하이픈 없이 저장된 legacy 값도 잡도록 정규형으로 변환 후 분리한다.
|
||||
*/
|
||||
public static boolean containsMobileSegment(String password, String mobileNumber) {
|
||||
if (password == null || mobileNumber == null || mobileNumber.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String tmpPw = password.toUpperCase();
|
||||
String[] mobileParts = PhoneNumberUtil.normalize(mobileNumber).split("-");
|
||||
for (String part : mobileParts) {
|
||||
if (!part.isEmpty() && tmpPw.contains(part)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static boolean isContinuous(int first, int third) {
|
||||
// 첫 글자 A-Z / 0-9
|
||||
return (first > 47 && third < 58) || (first > 64 && third < 91);
|
||||
|
||||
+8
-3
@@ -4,6 +4,7 @@ import com.eactive.apim.portal.apps.login.constants.LoginConstants;
|
||||
import com.eactive.apim.portal.apps.login.constants.LoginFailureReason;
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserLogService;
|
||||
import com.eactive.apim.portal.common.exception.UserNotFoundException;
|
||||
import com.eactive.apim.portal.common.security.LoginLockPolicy;
|
||||
import com.eactive.apim.portal.common.util.HttpRequestUtil;
|
||||
import com.eactive.apim.portal.common.util.StringMaskingUtil;
|
||||
import com.eactive.apim.portal.common.util.StringRepeatUtil;
|
||||
@@ -47,14 +48,17 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final PortalUserLogService userLogService;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final LoginLockPolicy loginLockPolicy;
|
||||
|
||||
|
||||
public PortalAuthenticationFailureHandler(PortalUserRepository portalUserRepository,
|
||||
PortalUserLogService userLogService,
|
||||
MessageHandlerService messageHandlerService) {
|
||||
MessageHandlerService messageHandlerService,
|
||||
LoginLockPolicy loginLockPolicy) {
|
||||
this.portalUserRepository = portalUserRepository;
|
||||
this.userLogService = userLogService;
|
||||
this.messageHandlerService = messageHandlerService;
|
||||
this.loginLockPolicy = loginLockPolicy;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -73,15 +77,16 @@ public class PortalAuthenticationFailureHandler implements AuthenticationFailure
|
||||
PortalUser user = portalUserRepository.findPortalUserByEmailAddr(normalizedUsername)
|
||||
.orElseThrow(() -> new UserNotFoundException(normalizedUsername));
|
||||
|
||||
int lockCount = loginLockPolicy.lockCount();
|
||||
user.setLoginFailureCount(user.getLoginFailureCount() + 1);
|
||||
if (user.getLoginFailureCount() >= 5) {
|
||||
if (user.getLoginFailureCount() >= lockCount) {
|
||||
user.setAccountLockYn("Y");
|
||||
|
||||
// 계정 잠금 알림
|
||||
messageHandlerService.publishEvent(
|
||||
MessageCode.USER_ACCOUNT_LOCKED,
|
||||
MessageRecipient.of(user),
|
||||
Maps.of("reason", "5회 이상 로그인 실패로 인한 계정 잠금")) ;;
|
||||
Maps.of("reason", lockCount + "회 이상 로그인 실패로 인한 계정 잠금"));
|
||||
}
|
||||
portalUserRepository.save(user);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.config;
|
||||
|
||||
|
||||
import com.eactive.apim.portal.apps.user.service.PortalUserAuthService;
|
||||
import com.eactive.apim.portal.common.security.LoginLockPolicy;
|
||||
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
|
||||
@@ -28,6 +29,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
||||
private final PortalUserAuthService portalUserAuthService;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
private final LoginLockPolicy loginLockPolicy;
|
||||
|
||||
@Override
|
||||
@Transactional(noRollbackFor = {AuthenticationException.class})
|
||||
@@ -40,7 +42,7 @@ public class PortalAuthenticationManager implements AuthenticationManager {
|
||||
|
||||
|
||||
if (!user.isAccountNonLocked()) {
|
||||
if (user.getLoginFailureCount() >= 5) {
|
||||
if (user.getLoginFailureCount() >= loginLockPolicy.lockCount()) {
|
||||
throw new LockedException("계정이 잠겼습니다. 비밀번호 초기화 또는 관리자에게 문의하세요.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.eactive.apim.portal.custom.config;
|
||||
|
||||
//import com.eactive.ext.djb.safedb.DjbSafedbWrapper;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@@ -17,8 +16,5 @@ public class DjbPasswordEncoder implements PasswordEncoder {
|
||||
@Override
|
||||
public boolean matches(CharSequence rawPassword, String encodedPassword) {
|
||||
return bcryptEncoder.matches(rawPassword, encodedPassword);
|
||||
// DjbSafedbWrapper safedb = DjbSafedbWrapper.getInstance();
|
||||
// String bcryptHash = safedb.decryptNotRnno(encodedPassword);
|
||||
// return bcryptEncoder.matches(rawPassword, bcryptHash);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-13
@@ -3,20 +3,13 @@ package com.eactive.apim.portal.djb.community.qna.comment.repository;
|
||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||
import com.eactive.eai.data.jpa.BaseRepository;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 댓글 작성자(내부 직원) 표시용 TSEAIRM02 조회.
|
||||
*
|
||||
* <p>역할 기반 알림 대상 조회는
|
||||
* {@code com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository} 로 분리했다.</p>
|
||||
*/
|
||||
@EMSDataSource
|
||||
public interface UserInfoRepository extends BaseRepository<UserInfo, String> {
|
||||
|
||||
/**
|
||||
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
|
||||
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
|
||||
*/
|
||||
@Query(value = "SELECT * FROM TSEAIRM02 t"
|
||||
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
|
||||
nativeQuery = true)
|
||||
List<UserInfo> findByRoleContaining(@Param("role") String role);
|
||||
}
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.comment.service;
|
||||
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.constant.DjbAdminRole;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.service.MessageHandlerService;
|
||||
import com.eactive.apim.portal.template.service.MessageRecipient;
|
||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Q&A 등록/댓글 등록 시 portal-admin 역할(TSEAIRM02)을 가진 관리자 전원에게
|
||||
* 알림 메시지를 발행한다. 발송 실패는 트랜잭션 롤백을 유발하지 않는다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CommunityAdminNotifier {
|
||||
|
||||
private final UserInfoRepository userInfoRepository;
|
||||
private final MessageHandlerService messageHandlerService;
|
||||
|
||||
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
|
||||
try {
|
||||
List<UserInfo> admins = userInfoRepository.findByRoleContaining(DjbAdminRole.PORTAL_ADMIN);
|
||||
if (admins == null || admins.isEmpty()) {
|
||||
log.warn("portal-admin 역할 관리자가 없습니다 — 알림 미발송 code={}", code.name());
|
||||
return;
|
||||
}
|
||||
for (UserInfo admin : admins) {
|
||||
try {
|
||||
MessageRecipient recipient = toRecipient(admin);
|
||||
messageHandlerService.publishEvent(code, recipient, params);
|
||||
} catch (Exception e) {
|
||||
log.warn("portal-admin 개별 알림 발행 실패 — userid={}, code={}",
|
||||
admin.getUserid(), code.name(), e);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("portal-admin 알림 발행 실패 — code={}", code.name(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private MessageRecipient toRecipient(UserInfo admin) {
|
||||
MessageRecipient r = new MessageRecipient();
|
||||
r.setUsername(admin.getUsername());
|
||||
r.setUserId(admin.getEmad());
|
||||
r.setPhone(admin.getCphnno());
|
||||
r.setMessengerId(admin.getUserid());
|
||||
return r;
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -8,6 +8,7 @@ import com.eactive.apim.portal.djb.community.qna.comment.dto.InquiryCommentDTO;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.comment.repository.UserInfoRepository;
|
||||
import com.eactive.apim.portal.djb.community.qna.constant.DjbInquiryStatus;
|
||||
import com.eactive.apim.portal.djb.swing.SwingNotifier;
|
||||
import com.eactive.apim.portal.djb.community.qna.support.InquiryCommentPermissionChecker;
|
||||
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
|
||||
import com.eactive.apim.portal.portaluser.entity.PortalUser;
|
||||
@@ -47,7 +48,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
private final InquiryCommentService commentService;
|
||||
private final InquiryCommentRepository commentRepository;
|
||||
private final InquiryCommentPermissionChecker permissionChecker;
|
||||
private final CommunityAdminNotifier adminNotifier;
|
||||
private final SwingNotifier swingNotifier;
|
||||
private final PortalUserRepository portalUserRepository;
|
||||
private final UserInfoRepository userInfoRepository;
|
||||
|
||||
@@ -107,7 +108,7 @@ public class InquiryCommentFacadeImpl implements InquiryCommentFacade {
|
||||
params.put("inquirySubject", inquiry.getInquirySubject());
|
||||
params.put("commentContent", comment.getCommentDetail());
|
||||
params.put("writerName", current.getUserName());
|
||||
adminNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
|
||||
swingNotifier.notifyPortalAdmins(MessageCode.INQUIRY_COMMENT_CREATED, params);
|
||||
}
|
||||
|
||||
private Map<String, Writer> resolveWriters(List<InquiryComment> comments) {
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.eactive.apim.portal.djb.community.qna.constant;
|
||||
|
||||
public final class DjbAdminRole {
|
||||
|
||||
/** TSEAIRM02.roleidnfiname 컬럼에서 포털 관리자를 식별하는 값. */
|
||||
public static final String PORTAL_ADMIN = "portal-admin";
|
||||
|
||||
private DjbAdminRole() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.eactive.apim.portal.djb.swing;
|
||||
|
||||
/**
|
||||
* Swing 메신저 수신자 ID 가 행번(직원번호)이 아닐 때의 처리 정책.
|
||||
*
|
||||
* <p>Swing 메신저는 {@code TSEAIRM02.USERID} 를 수신자 코드로 사용하며 운영 직원은 숫자 행번을 쓴다.
|
||||
* portal-admin 역할이 부여된 개발용 계정(영문 ID 등)은 Swing 에 존재하지 않아 발송이 무의미하므로
|
||||
* 이 정책으로 처리 방식을 선택한다. PTL_PROPERTY
|
||||
* {@code Portal / djb.swing.notify.non-employee.policy} 로 지정한다.</p>
|
||||
*/
|
||||
public enum EmployeeIdPolicy {
|
||||
|
||||
/** 정상 발송 (PTL_MESSAGE_REQUEST 에 PENDING 적재) */
|
||||
SEND,
|
||||
|
||||
/** 발송 + WARN 로그 */
|
||||
SEND_WARN,
|
||||
|
||||
/** 무시 처리 — REQUEST_STATUS='SKIPPED' 로 적재해 이력만 남기고 배치는 수집하지 않음 */
|
||||
SKIP,
|
||||
|
||||
/** 거부 — PTL_MESSAGE_REQUEST 에 적재하지 않음 + INFO 로그 */
|
||||
REJECT_LOG,
|
||||
|
||||
/** 완전 거부 — 적재하지 않고 로그도 남기지 않음 */
|
||||
REJECT;
|
||||
|
||||
/** 프로퍼티 문자열 → enum. 값이 없거나 알 수 없으면 기본값 {@link #SKIP}. */
|
||||
public static EmployeeIdPolicy from(String value) {
|
||||
if (value != null) {
|
||||
String trimmed = value.trim();
|
||||
for (EmployeeIdPolicy policy : values()) {
|
||||
if (policy.name().equalsIgnoreCase(trimmed)) {
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
}
|
||||
return SKIP;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.eactive.apim.portal.djb.swing;
|
||||
|
||||
import com.eactive.apim.portal.djb.swing.repository.SwingStaffRepository;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import com.eactive.apim.portal.template.entity.MessageRequest;
|
||||
import com.eactive.apim.portal.template.entity.MessageTemplate;
|
||||
import com.eactive.apim.portal.template.repository.MessageRequestRepository;
|
||||
import com.eactive.apim.portal.template.repository.MessageTemplateRepository;
|
||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Swing 메신저 발송 요청을 PTL_MESSAGE_REQUEST 에 직접 적재한다.
|
||||
*
|
||||
* <p>TSEAIRM02(직원) → PTL_MESSAGE_TEMPLATE(본문) → PTL_MESSAGE_REQUEST(발송 요청) 순으로
|
||||
* 처리하며, 실제 발송은 기존과 동일하게 eapim-admin 의 {@code UmsDispatchJob}(5초 주기)이
|
||||
* {@code REQUEST_STATUS='PENDING'} 행을 수집해 수행한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SwingMessageWriter {
|
||||
|
||||
private static final String MESSAGE_TYPE_MESSENGER = "MESSENGER";
|
||||
private static final String STATUS_PENDING = "PENDING";
|
||||
/** 행번 아닌 ID 를 무시 처리할 때 쓰는 신설 상태값. 배치(PENDING 수집)가 건드리지 않는다. */
|
||||
private static final String STATUS_SKIPPED = "SKIPPED";
|
||||
private static final String ENABLED = "Y";
|
||||
|
||||
private final SwingStaffRepository swingStaffRepository;
|
||||
private final MessageTemplateRepository messageTemplateRepository;
|
||||
private final MessageRequestRepository messageRequestRepository;
|
||||
private final SwingNotifyProperties properties;
|
||||
|
||||
@Transactional
|
||||
public void write(MessageCode code, Map<String, Object> params) {
|
||||
String role = properties.getTargetRole();
|
||||
List<UserInfo> staffs = swingStaffRepository.findByRole(role);
|
||||
if (staffs == null || staffs.isEmpty()) {
|
||||
log.warn("Swing 알림 대상 직원이 없습니다 — role={}, code={}", role, code.name());
|
||||
return;
|
||||
}
|
||||
|
||||
MessageTemplate template = messageTemplateRepository.findById(code.name()).orElse(null);
|
||||
if (template == null) {
|
||||
log.warn("메세지 템플릿이 존재하지 않습니다 — code={}", code.name());
|
||||
return;
|
||||
}
|
||||
if (!ENABLED.equalsIgnoreCase(template.getEnableMessenger())) {
|
||||
log.warn("메신저 발송이 비활성화된 템플릿입니다 — code={}, enableMessenger={}",
|
||||
code.name(), template.getEnableMessenger());
|
||||
return;
|
||||
}
|
||||
|
||||
SwingNotifyProperties.UmsMessengerIds umsIds = properties.getUmsMessengerIds();
|
||||
EmployeeIdPolicy policy = properties.getNonEmployeePolicy();
|
||||
|
||||
for (UserInfo staff : staffs) {
|
||||
try {
|
||||
writeOne(code, template, params, staff, umsIds, policy);
|
||||
} catch (Exception e) {
|
||||
log.warn("Swing 알림 적재 실패 — userid={}, code={}", staff.getUserid(), code.name(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void writeOne(MessageCode code, MessageTemplate template, Map<String, Object> params,
|
||||
UserInfo staff, SwingNotifyProperties.UmsMessengerIds umsIds,
|
||||
EmployeeIdPolicy policy) {
|
||||
|
||||
String messengerId = staff.getUserid();
|
||||
String requestStatus = STATUS_PENDING;
|
||||
|
||||
if (!properties.isEmployeeId(messengerId)) {
|
||||
switch (policy) {
|
||||
case REJECT:
|
||||
return;
|
||||
case REJECT_LOG:
|
||||
log.info("행번이 아닌 ID — 발송 거부(미적재). userid={}, code={}", messengerId, code.name());
|
||||
return;
|
||||
case SKIP:
|
||||
log.info("행번이 아닌 ID — 무시 처리({}). userid={}, code={}",
|
||||
STATUS_SKIPPED, messengerId, code.name());
|
||||
requestStatus = STATUS_SKIPPED;
|
||||
break;
|
||||
case SEND_WARN:
|
||||
log.warn("행번이 아닌 ID — 발송 진행. Swing 미등록 계정이면 발송 실패한다. userid={}, code={}",
|
||||
messengerId, code.name());
|
||||
break;
|
||||
case SEND:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> messageParams = toStringParams(params);
|
||||
messageParams.put("userId", messengerId);
|
||||
messageParams.putIfAbsent("USER_ID", messengerId);
|
||||
if (StringUtils.hasText(staff.getUsername())) {
|
||||
messageParams.put("userName", staff.getUsername());
|
||||
messageParams.putIfAbsent("USER_NAME", staff.getUsername());
|
||||
}
|
||||
|
||||
MessageRequest request = new MessageRequest();
|
||||
request.setMessageCode(code);
|
||||
request.setMessageType(MESSAGE_TYPE_MESSENGER);
|
||||
request.setSubject(buildMessage(template.getSubjectTemplate(), messageParams));
|
||||
request.setMessage(buildMessage(template.getMessengerTemplate(), messageParams));
|
||||
request.setUsername(staff.getUsername());
|
||||
request.setUserId(messengerId);
|
||||
request.setMessengerId(messengerId);
|
||||
request.setEaiInterfaceId(umsIds.getInterfaceId());
|
||||
request.setServiceId(umsIds.getServiceId());
|
||||
request.setRequestDate(LocalDateTime.now());
|
||||
request.setRequestStatus(requestStatus);
|
||||
// email/phone 은 설정하지 않는다 — 메신저 전용 경로라 개인정보를 적재할 이유가 없다.
|
||||
|
||||
messageRequestRepository.save(request);
|
||||
log.debug("Swing 알림 적재 — code={}, userid={}, status={}", code.name(), messengerId, requestStatus);
|
||||
}
|
||||
|
||||
/** 수신자별로 파라미터를 복사한다(공유 맵을 오염시키지 않기 위함). */
|
||||
private static Map<String, String> toStringParams(Map<String, Object> params) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
if (params != null) {
|
||||
for (Map.Entry<String, Object> entry : params.entrySet()) {
|
||||
if (entry.getKey() == null || entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
result.put(entry.getKey(), String.valueOf(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 템플릿 본문의 {@code %KEY%} 플레이스홀더를 치환한다(정규식 아닌 리터럴 치환). */
|
||||
private static String buildMessage(String contents, Map<String, String> params) {
|
||||
if (!StringUtils.hasText(contents)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
String result = contents;
|
||||
for (Map.Entry<String, String> entry : params.entrySet()) {
|
||||
if (entry.getKey() == null || entry.getValue() == null) {
|
||||
continue;
|
||||
}
|
||||
result = result.replace("%" + entry.getKey() + "%", entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.eactive.apim.portal.djb.swing;
|
||||
|
||||
import com.eactive.apim.portal.djb.swing.config.SwingAsyncConfig;
|
||||
import com.eactive.apim.portal.template.entity.MessageCode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* DjBankSwing(사내 메신저) 전용 알림 발행자.
|
||||
*
|
||||
* <p>포털 사용자용 범용 발송 경로({@code MessageHandlerService} → {@code MessageSendService})를
|
||||
* 타지 않고 TSEAIRM02 / PTL_MESSAGE_TEMPLATE / PTL_MESSAGE_REQUEST 를 직접 다룬다.
|
||||
* 내부 직원은 수신자 ID 체계(행번)와 채널(메신저 단일)이 포털 사용자와 완전히 달라 분리했다.</p>
|
||||
*
|
||||
* <p>비동기 실행이므로 게시물 등록 트랜잭션과 분리된다. 발송 적재 실패가 등록을 되돌리지 않는 대신,
|
||||
* 등록이 롤백돼도 알림은 남을 수 있다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SwingNotifier {
|
||||
|
||||
private final SwingMessageWriter swingMessageWriter;
|
||||
|
||||
/**
|
||||
* 대상 역할(PTL_PROPERTY {@code djb.swing.notify.role}) 직원 전원에게 메신저 알림을 적재한다.
|
||||
*
|
||||
* @param code 메세지 코드 (PTL_MESSAGE_TEMPLATE.MESSAGE_CODE 와 동일)
|
||||
* @param params 템플릿 {@code %KEY%} 치환 파라미터
|
||||
*/
|
||||
@Async(SwingAsyncConfig.EXECUTOR)
|
||||
public void notifyPortalAdmins(MessageCode code, Map<String, Object> params) {
|
||||
try {
|
||||
swingMessageWriter.write(code, params);
|
||||
} catch (Exception e) {
|
||||
log.warn("Swing 알림 발행 실패 — code={}", code == null ? null : code.name(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.eactive.apim.portal.djb.swing;
|
||||
|
||||
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Swing 알림 관련 PTL_PROPERTY 접근 래퍼.
|
||||
*
|
||||
* <p>그룹 {@code Portal}, 점 구분 소문자 키 관례를 따른다.
|
||||
* {@link PortalPropertyService#getOrCreateProperty} 는 최초 접근 시 기본값으로 DB row 를
|
||||
* 생성하므로 별도 초기 데이터 없이도 동작한다.</p>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SwingNotifyProperties {
|
||||
|
||||
public static final String GROUP = "Portal";
|
||||
|
||||
public static final String KEY_TARGET_ROLE = "djb.swing.notify.role";
|
||||
public static final String KEY_EMPLOYEE_ID_PATTERN = "djb.swing.notify.employee-id.pattern";
|
||||
public static final String KEY_NON_EMPLOYEE_POLICY = "djb.swing.notify.non-employee.policy";
|
||||
|
||||
/** UMS 메신저 연계 식별자. 기존 발송 경로(MessageSendService)와 같은 키를 그대로 읽는다. */
|
||||
public static final String KEY_MESSENGER_IF_ID = "djb.ums.messenger.if_id";
|
||||
public static final String KEY_MESSENGER_TX_ID = "djb.ums.messenger.tx_id";
|
||||
|
||||
private static final String DEFAULT_TARGET_ROLE = "portal-admin";
|
||||
private static final String DEFAULT_EMPLOYEE_ID_PATTERN = "^[0-9]+$";
|
||||
|
||||
private final PortalPropertyService portalPropertyService;
|
||||
|
||||
/** 컴파일된 행번 정규식 캐시. 프로퍼티 값이 바뀌면 다시 컴파일한다. */
|
||||
private volatile String cachedPatternValue;
|
||||
private volatile Pattern cachedPattern;
|
||||
|
||||
/** 알림 대상 직원을 식별하는 TSEAIRM02.ROLEIDNFINAME 역할명 */
|
||||
public String getTargetRole() {
|
||||
return resolve(KEY_TARGET_ROLE, DEFAULT_TARGET_ROLE,
|
||||
"Swing 알림 대상 직원 역할명 (TSEAIRM02.ROLEIDNFINAME 의 콤마 구분 토큰)");
|
||||
}
|
||||
|
||||
/** 행번 아닌 ID 처리 정책 */
|
||||
public EmployeeIdPolicy getNonEmployeePolicy() {
|
||||
return EmployeeIdPolicy.from(resolve(KEY_NON_EMPLOYEE_POLICY, EmployeeIdPolicy.SKIP.name(),
|
||||
"행번 아닌 ID 처리 정책 (SEND/SEND_WARN/SKIP/REJECT_LOG/REJECT)"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 행번(직원번호) 형식인지 여부. 정규식이 잘못 지정된 경우 기본 정규식으로 폴백한다.
|
||||
*
|
||||
* @param userId TSEAIRM02.USERID
|
||||
*/
|
||||
public boolean isEmployeeId(String userId) {
|
||||
if (userId == null || userId.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return employeeIdPattern().matcher(userId.trim()).matches();
|
||||
}
|
||||
|
||||
/** UMS 메신저 연계 식별자(EAI 인터페이스 ID / 서비스 ID) */
|
||||
public UmsMessengerIds getUmsMessengerIds() {
|
||||
// 환경별 실제 연계값이라 임의 기본값을 만들면 위험 — getOrCreateProperty 대신 조회만 한다.
|
||||
Map<String, String> portalProperties = portalPropertyService.getPortalPropertiesAsMap(GROUP);
|
||||
return new UmsMessengerIds(
|
||||
portalProperties.get(KEY_MESSENGER_IF_ID),
|
||||
portalProperties.get(KEY_MESSENGER_TX_ID));
|
||||
}
|
||||
|
||||
private Pattern employeeIdPattern() {
|
||||
String value = resolve(KEY_EMPLOYEE_ID_PATTERN, DEFAULT_EMPLOYEE_ID_PATTERN,
|
||||
"행번(직원번호) 판별 정규식. 미매치 시 개발용 ID 로 간주");
|
||||
|
||||
Pattern cached = this.cachedPattern;
|
||||
if (cached != null && value != null && value.equals(this.cachedPatternValue)) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
Pattern compiled;
|
||||
try {
|
||||
compiled = Pattern.compile(value);
|
||||
} catch (Exception e) {
|
||||
log.warn("행번 판별 정규식이 잘못되어 기본값으로 대체합니다 — pattern={}", value, e);
|
||||
compiled = Pattern.compile(DEFAULT_EMPLOYEE_ID_PATTERN);
|
||||
}
|
||||
this.cachedPatternValue = value;
|
||||
this.cachedPattern = compiled;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
private String resolve(String key, String defaultValue, String description) {
|
||||
return portalPropertyService.getOrCreateProperty(GROUP, key, defaultValue, description);
|
||||
}
|
||||
|
||||
/** UMS 메신저 연계 식별자 묶음 */
|
||||
@Getter
|
||||
public static class UmsMessengerIds {
|
||||
private final String interfaceId;
|
||||
private final String serviceId;
|
||||
|
||||
public UmsMessengerIds(String interfaceId, String serviceId) {
|
||||
this.interfaceId = interfaceId;
|
||||
this.serviceId = serviceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.eactive.apim.portal.djb.swing.config;
|
||||
|
||||
import java.util.concurrent.Executor;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* Swing 알림 전용 비동기 실행기.
|
||||
*
|
||||
* <p>게시물 등록 트랜잭션이 메신저 알림 적재를 기다리지 않도록 분리한다.
|
||||
* eapim-admin 의 {@code WebhookAsyncConfig} 와 동일한 구성.</p>
|
||||
*/
|
||||
@Configuration
|
||||
@EnableAsync
|
||||
public class SwingAsyncConfig {
|
||||
|
||||
public static final String EXECUTOR = "swingNotifyExecutor";
|
||||
|
||||
@Bean(name = EXECUTOR)
|
||||
public Executor swingNotifyExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(2);
|
||||
executor.setMaxPoolSize(10);
|
||||
executor.setQueueCapacity(100);
|
||||
executor.setThreadNamePrefix("swing-notify-");
|
||||
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||
executor.setAwaitTerminationSeconds(30);
|
||||
executor.initialize();
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.eactive.apim.portal.djb.swing.repository;
|
||||
|
||||
import com.eactive.apim.portal.user.entity.UserInfo;
|
||||
import com.eactive.eai.rms.data.EMSDataSource;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Swing 알림 대상 직원(TSEAIRM02) 조회 전용 리포지토리.
|
||||
*/
|
||||
@Repository
|
||||
@EMSDataSource
|
||||
public interface SwingStaffRepository extends JpaRepository<UserInfo, String> {
|
||||
|
||||
/**
|
||||
* TSEAIRM02.roleidnfiname 컬럼은 콤마로 구분된 복수 역할을 저장한다.
|
||||
* (예: {@code "admin,portal-admin"}). Oracle native query로 콤마 토큰 매치.
|
||||
*/
|
||||
@Query(value = "SELECT * FROM TSEAIRM02 t"
|
||||
+ " WHERE ',' || t.ROLEIDNFINAME || ',' LIKE '%,' || :role || ',%'",
|
||||
nativeQuery = true)
|
||||
List<UserInfo> findByRole(@Param("role") String role);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1703,6 +1703,19 @@ hr {
|
||||
}
|
||||
}
|
||||
|
||||
.footer-link--external {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.footer-link-external-icon {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-separator {
|
||||
color: #D1D5DB;
|
||||
font-size: 16px;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,8 +2,10 @@
|
||||
* 비밀번호 문자열 정책 라이브 검증 (공용)
|
||||
*
|
||||
* 서버 검증기 PasswordRuleValidator.isValid(= @PasswordRule) 의
|
||||
* "문자열" 규칙을 그대로 클라이언트로 포팅한다. 아이디/휴대전화 포함 여부는
|
||||
* 민감정보 노출을 피하기 위해 서버 검증에만 맡긴다.
|
||||
* 규칙을 클라이언트로 포팅한다. 아이디/휴대전화 포함 규칙(noid/nomobile)은
|
||||
* 사용자가 폼에 직접 입력한 값이 페이지에 이미 있을 때만 ul 의
|
||||
* data-context-loginid/data-context-mobile 로 연결해 쓴다 — DB 값을 새로
|
||||
* 내려받아야 하는 화면(비밀번호 변경)은 서버 AJAX(/password/content-check)로 판정한다.
|
||||
*
|
||||
* 사용법(마크업 구동):
|
||||
* <ul class="password-policy-checklist" data-password-input="newPassword">
|
||||
@@ -31,7 +33,7 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
// 규칙별 판정 함수 (통과=true)
|
||||
// 규칙별 판정 함수 (통과=true). ctx = { loginId, mobile } — 값이 없으면 해당 규칙은 통과 처리
|
||||
var RULES = {
|
||||
length: function (pw) { return pw.length >= 8 && pw.length <= 50; },
|
||||
letter: function (pw) { return /[a-zA-Z]/.test(pw); },
|
||||
@@ -39,23 +41,46 @@
|
||||
special: function (pw) { return /[^A-Za-z0-9_]/.test(pw); }, // 서버 정규식 \W 기준 (밑줄 제외)
|
||||
nospace: function (pw) { return !/\s/.test(pw); },
|
||||
norepeat: function (pw) { return !/(\w)\1\1/.test(pw.toUpperCase()); },
|
||||
noseq: function (pw) { return !hasSequential(pw); }
|
||||
noseq: function (pw) { return !hasSequential(pw); },
|
||||
// 아이디(이메일 local part) 포함 금지 — 서버 PasswordRuleValidator.containsLoginIdLocalPart 포팅
|
||||
noid: function (pw, ctx) {
|
||||
var id = ctx && ctx.loginId ? String(ctx.loginId).split('@')[0].toUpperCase() : '';
|
||||
return !id || pw.toUpperCase().indexOf(id) === -1;
|
||||
},
|
||||
// 휴대전화 하이픈 세그먼트 포함 금지 — 서버 containsMobileSegment 포팅
|
||||
nomobile: function (pw, ctx) {
|
||||
var m = ctx && ctx.mobile ? String(ctx.mobile) : '';
|
||||
if (!m) { return true; }
|
||||
var up = pw.toUpperCase();
|
||||
var parts = m.split('-');
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
if (parts[i] && up.indexOf(parts[i]) !== -1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 문자열 규칙 통과 여부
|
||||
function isValid(pw) {
|
||||
// 전체 문자열 규칙 통과 여부 (ctx 미전달 시 noid/nomobile 은 통과 — 서버 검증에 위임)
|
||||
function isValid(pw, ctx) {
|
||||
if (!pw) {
|
||||
return false;
|
||||
}
|
||||
for (var key in RULES) {
|
||||
if (RULES.hasOwnProperty(key) && !RULES[key](pw)) {
|
||||
if (RULES.hasOwnProperty(key) && !RULES[key](pw, ctx || {})) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 체크리스트(ul) 하나를 대상 input 에 바인딩
|
||||
// bind 된 체크리스트들의 update 함수 목록 (컨텍스트 값 변경 시 refresh 용)
|
||||
var updaters = [];
|
||||
|
||||
// 체크리스트(ul) 하나를 대상 input 에 바인딩.
|
||||
// ul 의 data-context-loginid / data-context-mobile 속성에 소스 input 의 id 를 주면
|
||||
// noid/nomobile 규칙이 해당 값 기준으로 라이브 판정된다.
|
||||
function bind(input, list) {
|
||||
var $input = (input && input.jquery) ? input : $(input);
|
||||
var $list = (list && list.jquery) ? list : $(list);
|
||||
@@ -64,8 +89,18 @@
|
||||
return;
|
||||
}
|
||||
|
||||
function ctxValue(attr) {
|
||||
var id = $list.attr(attr);
|
||||
var el = id ? document.getElementById(id) : null;
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
function update() {
|
||||
var pw = $input.val() || '';
|
||||
var ctx = {
|
||||
loginId: ctxValue('data-context-loginid'),
|
||||
mobile: ctxValue('data-context-mobile')
|
||||
};
|
||||
$items.each(function () {
|
||||
var $li = $(this);
|
||||
var rule = RULES[$li.attr('data-rule')];
|
||||
@@ -76,15 +111,29 @@
|
||||
if (pw.length === 0) {
|
||||
$li.addClass('is-idle');
|
||||
} else {
|
||||
$li.addClass(rule(pw) ? 'is-pass' : 'is-fail');
|
||||
$li.addClass(rule(pw, ctx) ? 'is-pass' : 'is-fail');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 컨텍스트 소스 input 이 직접 타이핑되는 경우도 즉시 반영
|
||||
['data-context-loginid', 'data-context-mobile'].forEach(function (attr) {
|
||||
var id = $list.attr(attr);
|
||||
if (id && document.getElementById(id)) {
|
||||
$(document.getElementById(id)).on('input.passwordPolicy change.passwordPolicy', update);
|
||||
}
|
||||
});
|
||||
|
||||
updaters.push(update);
|
||||
$input.on('input.passwordPolicy', update);
|
||||
update();
|
||||
}
|
||||
|
||||
// hidden input 등 이벤트 없이 값이 세팅되는 컨텍스트 변경 후 수동 재판정
|
||||
function refresh() {
|
||||
updaters.forEach(function (u) { u(); });
|
||||
}
|
||||
|
||||
// 마크업 구동 자동 초기화
|
||||
function init(root) {
|
||||
var $root = root ? $(root) : $(document);
|
||||
@@ -101,7 +150,8 @@
|
||||
RULES: RULES,
|
||||
isValid: isValid,
|
||||
bind: bind,
|
||||
init: init
|
||||
init: init,
|
||||
refresh: refresh
|
||||
};
|
||||
|
||||
$(function () {
|
||||
|
||||
@@ -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 - 확인 메시지
|
||||
@@ -407,8 +511,12 @@ const customPopups = {
|
||||
return;
|
||||
}
|
||||
|
||||
// 하이픈 유무 무관 입력을 저장 표준(010-1234-5678)으로 통일해 전달
|
||||
const formattedMobile = mobile.replace(/-/g, '')
|
||||
.replace(/^(01[016-9])(\d{3,4})(\d{4})$/, '$1-$2-$3');
|
||||
|
||||
if (typeof onConfirm === 'function') {
|
||||
onConfirm(mobile, notifyConsent);
|
||||
onConfirm(formattedMobile, notifyConsent);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -65,6 +65,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
.footer-link--external {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.footer-link-external-icon {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-separator {
|
||||
color: #D1D5DB;
|
||||
font-size: 16px;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -70,8 +70,11 @@
|
||||
class="policy-text">동일 문자 3자리 이상 반복 불가</span></li>
|
||||
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span
|
||||
class="policy-text">연속된 문자/숫자 3자리 이상 불가</span></li>
|
||||
<li data-rule="noid-server" class="is-idle"><span class="policy-icon"></span><span
|
||||
class="policy-text">아이디(이메일) 포함 불가</span></li>
|
||||
<li data-rule="nomobile-server" class="is-idle"><span class="policy-icon"></span><span
|
||||
class="policy-text">휴대전화 번호 포함 불가</span></li>
|
||||
</ul>
|
||||
<p class="password-policy-note">※ 아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions" style="justify-content: flex-end;">
|
||||
@@ -89,6 +92,52 @@
|
||||
customPopups.showAlert([[${ error }]]);
|
||||
})
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
// 아이디/휴대전화 포함 여부 라이브 체크 — 민감정보를 페이지에 내리지 않고
|
||||
// 서버(/password/content-check, 세션 사용자 기준)로 판정한다. data-rule 이
|
||||
// RULES 에 없는 *-server 항목은 password-policy.js 가 건드리지 않는다.
|
||||
(function () {
|
||||
var input = document.getElementById('newPassword');
|
||||
var liId = document.querySelector('li[data-rule="noid-server"]');
|
||||
var liMobile = document.querySelector('li[data-rule="nomobile-server"]');
|
||||
if (!input || !liId || !liMobile) return;
|
||||
|
||||
function setState(li, state) {
|
||||
li.classList.remove('is-idle', 'is-pass', 'is-fail');
|
||||
li.classList.add(state);
|
||||
}
|
||||
|
||||
var csrfToken = document.querySelector('meta[name="_csrf"]');
|
||||
var csrfHeader = document.querySelector('meta[name="_csrf_header"]');
|
||||
var timer = null;
|
||||
|
||||
input.addEventListener('input', function () {
|
||||
var pw = input.value;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!pw) {
|
||||
setState(liId, 'is-idle');
|
||||
setState(liMobile, 'is-idle');
|
||||
return;
|
||||
}
|
||||
timer = setTimeout(function () {
|
||||
var headers = {};
|
||||
if (csrfToken && csrfHeader) {
|
||||
headers[csrfHeader.content] = csrfToken.content;
|
||||
}
|
||||
$.ajax({
|
||||
url: /*[[@{/password/content-check}]]*/ '/password/content-check',
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
data: { password: pw }
|
||||
}).done(function (res) {
|
||||
if (input.value !== pw) return; // 입력이 이미 바뀐 응답은 무시
|
||||
setState(liId, res.idIncluded ? 'is-fail' : 'is-pass');
|
||||
setState(liMobile, res.mobileIncluded ? 'is-fail' : 'is-pass');
|
||||
});
|
||||
}, 300);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<script th:inline="javascript">
|
||||
// 반영 직전 2FA: twofaRequired 면 제출을 가로채 2FA 팝업 → 성공 시 실제 제출.
|
||||
(function () {
|
||||
|
||||
@@ -402,16 +402,17 @@
|
||||
const last = document.getElementById('newMobileLast')?.value.trim();
|
||||
|
||||
if (prefix === '선택' || !middle || !last) return null;
|
||||
return prefix + middle + last;
|
||||
// 저장 표준(하이픈 정규형)에 맞춰 조합
|
||||
return `${prefix}-${middle}-${last}`;
|
||||
},
|
||||
|
||||
// 기존 휴대폰 번호 가져오기 (하이픈 없이)
|
||||
// 기존 휴대폰 번호 가져오기 (하이픈 정규형)
|
||||
getExistingMobileNumber: () => {
|
||||
const prefix = document.querySelector('[name="mobilePrefix"]')?.value;
|
||||
const middle = document.querySelector('[name="mobileMiddle"]')?.value;
|
||||
const last = document.querySelector('[name="mobileLast"]')?.value;
|
||||
|
||||
return prefix + middle + last;
|
||||
return `${prefix}-${middle}-${last}`;
|
||||
},
|
||||
|
||||
// 휴대폰 번호 변경 여부 확인
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
const originalPrefix = document.querySelector('input[name="mobilePrefix"]').value;
|
||||
const originalMiddle = document.querySelector('input[name="mobileMiddle"]').value;
|
||||
const originalLast = document.querySelector('input[name="mobileLast"]').value;
|
||||
const originalMobileNumber = `${originalPrefix}${originalMiddle}${originalLast}`;
|
||||
// 저장 표준(하이픈 정규형)에 맞춰 조합 — 미변경 제출 시에도 이 값이 그대로 전송된다
|
||||
const originalMobileNumber = `${originalPrefix}-${originalMiddle}-${originalLast}`;
|
||||
|
||||
// 현재 값들 가져오기
|
||||
const currentName = document.querySelector('input[name="userName"]').value.trim();
|
||||
@@ -118,7 +119,7 @@
|
||||
|
||||
// 하이픈 제거 후 비교
|
||||
const newMobileRaw = newMobileNumber.replace(/-/g, '');
|
||||
if (newMobileRaw !== originalMobileNumber) {
|
||||
if (newMobileRaw !== originalMobileNumber.replace(/-/g, '')) {
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,8 @@
|
||||
th:placeholder="#{portalUser.Register.pass}">
|
||||
<input type="hidden" name="isPasswordValid" id="isPasswordValid" />
|
||||
<div id="password-validation" class="org-validation-message"></div>
|
||||
<ul class="password-policy-checklist" data-password-input="password">
|
||||
<ul class="password-policy-checklist" data-password-input="password"
|
||||
data-context-loginid="loginId" data-context-mobile="mobileNumber">
|
||||
<li data-rule="length" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문/숫자/특수문자
|
||||
포함 8~50자</span></li>
|
||||
<li data-rule="letter" class="is-idle"><span class="policy-icon"></span><span class="policy-text">영문
|
||||
@@ -95,8 +96,11 @@
|
||||
3자리 이상 반복 불가</span></li>
|
||||
<li data-rule="noseq" class="is-idle"><span class="policy-icon"></span><span class="policy-text">연속된 문자/숫자
|
||||
3자리 이상 불가</span></li>
|
||||
<li data-rule="noid" class="is-idle"><span class="policy-icon"></span><span class="policy-text">아이디(이메일)
|
||||
포함 불가</span></li>
|
||||
<li data-rule="nomobile" class="is-idle"><span class="policy-icon"></span><span class="policy-text">휴대전화 번호
|
||||
포함 불가</span></li>
|
||||
</ul>
|
||||
<p class="password-policy-note">※ 아이디, 휴대전화 번호는 비밀번호에 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -141,6 +145,10 @@
|
||||
if (mobileNumberInput) {
|
||||
mobileNumberInput.value = formattedNumber;
|
||||
}
|
||||
// hidden 값 변경은 input 이벤트가 없으므로 체크리스트(nomobile) 수동 재판정
|
||||
if (window.PasswordPolicy) {
|
||||
PasswordPolicy.refresh();
|
||||
}
|
||||
return formattedNumber;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -7,31 +7,54 @@
|
||||
<div class="footer-content">
|
||||
<!-- Left Section -->
|
||||
<div class="footer-left">
|
||||
<img src="/img/logo/logo-jjb_white.png" alt="DJBank" class="footer-logo">
|
||||
<img src="/img/logo/logo-jjb.png" alt="DJBank" class="footer-logo">
|
||||
<div class="footer-links">
|
||||
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
|
||||
<span class="footer-separator">|</span>
|
||||
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do" target="_blank"
|
||||
rel="noopener noreferrer" class="footer-link">개인정보처리방침</a>
|
||||
rel="noopener noreferrer" class="footer-link footer-link--external">개인정보처리방침<svg
|
||||
class="footer-link-external-icon" width="14" height="14" viewBox="0 0 16 16" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
|
||||
<path d="M6.5 2.5H3.2A1.2 1.2 0 0 0 2 3.7v9.1A1.2 1.2 0 0 0 3.2 14h9.1a1.2 1.2 0 0 0 1.2-1.2V9.5"
|
||||
stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9.5 2h4.5v4.5M14 2 7.5 8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"
|
||||
stroke-linejoin="round" />
|
||||
</svg><span class="sr-only">새 창으로 열림</span></a>
|
||||
</div>
|
||||
<p class="footer-copyright">Copyright © 2026 JEJU Bank. All Rights Reserved.</p>
|
||||
</div>
|
||||
|
||||
<!-- Right Section -->
|
||||
<div class="footer-right">
|
||||
<div class="footer-related-sites">
|
||||
<select class="related-sites-select">
|
||||
<option>DJBank 관련 사이트</option>
|
||||
<option>DJBank 홈페이지</option>
|
||||
<option>DJBank 인터넷뱅킹</option>
|
||||
<option>DJBank 모바일뱅킹</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
<body>
|
||||
<footer th:fragment="footerFragment" class="global-footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<!-- Left Section -->
|
||||
<div class="footer-left">
|
||||
<img src="/img/logo/logo-jjb_white.png" alt="DJBank" class="footer-logo">
|
||||
<div class="footer-links">
|
||||
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
|
||||
<span class="footer-separator">|</span>
|
||||
<a href="https://www.jejubank.co.kr/hmpg/csct/secuCenr/ptctPlcy/procsPlcy/ctnt.do" target="_blank"
|
||||
rel="noopener noreferrer" class="footer-link">개인정보처리방침</a>
|
||||
</div>
|
||||
<p class="footer-copyright">Copyright © 2026 JEJU Bank. All Rights Reserved.</p>
|
||||
</div>
|
||||
|
||||
<!-- Right Section -->
|
||||
<div class="footer-right">
|
||||
<div class="footer-related-sites">
|
||||
<select class="related-sites-select">
|
||||
<option>DJBank 관련 사이트</option>
|
||||
<option>DJBank 홈페이지</option>
|
||||
<option>DJBank 인터넷뱅킹</option>
|
||||
<option>DJBank 모바일뱅킹</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="footer-contact" th:text="'고객센터 ' + ${customerCenterContact}">고객센터 1588-3388</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -29,7 +29,7 @@
|
||||
<input type="tel"
|
||||
id="userInviteMobileInput"
|
||||
class="pop_input_field"
|
||||
placeholder="휴대폰 번호 ('-' 없이 입력)"
|
||||
placeholder="휴대폰 번호 (예: 010-1234-5678)"
|
||||
maxlength="13">
|
||||
<div id="userInvitePopupError" class="error-message"></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