11 Commits

Author SHA1 Message Date
Rinjae a456ed9bb0 사용자 Secret 관리 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 사용자 지정 Secret 필드 추가(WebhookRequest, DTO, Mapper)
- 사용자 Secret 평문 조회/갱신 로직 및 비밀번호 재인증 처리
- Secret 입력 UI 개선 및 스타일 수정
2026-08-25 15:16:57 +09:00
Rinjae 33c64972f8 - 브랜드명 템플릿화 적용 - 정적 리소스/HTML 다수 수정
- 서비스 소개 페이지 디자인/스타일 대폭 개선
- 웹훅/OAuth2 개발 가이드 문구 및 텍스트 업데이트/템플릿화
2026-08-25 13:42:30 +09:00
Rinjae cff0199d2d TestCleanupService 확장 - FileService/PasswordEncoder 추가 Mock 설정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
2026-08-24 21:16:23 +09:00
Rinjae 2e920eec96 4010-Q&A 테스트 계정 준비 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 법인개발자 계정 소속/비밀번호 초기화(`ensureTestCorpDeveloper`)
- 비밀번호만 재설정하는 테스트 API 추가(`resetTestPassword`)
- Q&A 테스트 문서 및 시나리오 수정(계정 준비 단계 8-3 추가)
2026-08-24 18:45:50 +09:00
Rinjae 65a262849e - 4020-partnership 테스트 추가 - 등록/조회/삭제 시나리오 구현
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- e2e/admin-partnership.page.ts - admin 접근 및 확인 로직 작성
- e2e/partnership-feedback.spec.ts - e2e 테스트 케이스 작성
2026-08-24 17:51:36 +09:00
Rinjae 7056306718 - 파트너십 글 단건 조회/삭제 메서드 추가 및 본인 확인 로직 적용
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- TestCleanupService 확장 - 테스트 글 삭제 및 첨부파일 정리 추가
- 레이아웃 개선 - footer 하단 고정(sticky) 처리
2026-08-24 17:21:47 +09:00
Rinjae 05e0599b26 - AppRequestRepository에 findAllByOrgAndClientName 메서드 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 클라이언트 신청 해지 프로세스 및 테스트 클린업 로직 구현
- Playwright API 키 관리/클라이언트 관리 경로 및 셀렉터 업데이트
2026-08-24 15:25:18 +09:00
Rinjae c223fa3124 PasswordServiceTest 추가 - 최근 비밀번호 중복 검사 로직 수정
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
TestCleanupService 확장 - 초대/회원 소속 관리 메서드 구현
API 추가 - 초대 삭제 및 소속 제외, 사용자 존재 여부 확인
2026-08-24 14:13:26 +09:00
Rinjae 78b1edff5a - 인증번호 재발송 제한 메시지 개선 - 남은 초 표시 추가
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 비밀번호 확인 필드명 일괄 변경(password2 → confirmPassword)
- 법인 관리자 탈퇴 제한 경고 메시지 추가 및 관련 로직 수정
- 임시 비밀번호 발급 시 기존 비밀번호 이력 저장 로직 추가
2026-08-24 13:17:14 +09:00
Rinjae b41a81bda3 Webhook API 표시 개선:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- WebhookDTO에 API 이름 포함 필드 추가(WebhookApiDTO)
- 프로필 활성화 뱃지 CSS 수정(고정 위치, 크기 축소) - 정적 자원 서빙 경로 설정 개선(staticBase 적용)
2026-08-24 10:37:12 +09:00
Rinjae 1ea2c89776 - 인덱스 통계 API 이용 건수 로직 수정 - 월누적 호출 건수 반영
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- GwAuthClient 리포지토리 다건 org용 메서드 추가
- 통계 DTO 주석 수정 및 관련 메서드 추가
2026-08-21 18:07:06 +09:00
81 changed files with 2599 additions and 361 deletions
@@ -22,4 +22,10 @@ public interface GwAuthClientRepository extends JpaRepository<GwAuthClient, Stri
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId = :orgId")
List<String> findClientIdsByOrgId(@Param("orgId") String orgId);
/**
* 다건 org 소속 CLIENTID 목록 (인덱스 페이지 전체 통계 집계용).
*/
@Query("SELECT c.clientId FROM GwAuthClient c WHERE c.orgId IN :orgIds")
List<String> findClientIdsByOrgIdIn(@Param("orgIds") List<String> orgIds);
}
@@ -41,7 +41,13 @@ public class ApprovalService {
approval.setApprovalType(ApprovalType.APP);
approval.setTargetId(request.getId());
approval.setRequester(SecurityUtil.getPortalAuthenticatedUser());
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "] " + request.getType().getDescription() + " 승인");
// 동일 법인의 신규/변경/해지 요청이 관리자 목록에서 같은 제목으로 보이면 대상 식별이 불가능하다.
// 클라이언트 이름을 제목에 포함해 운영자와 E2E 모두 정확한 승인 건을 검색할 수 있게 한다.
String clientName = request.getClientName();
String clientNamePart = clientName == null || clientName.trim().isEmpty()
? "" : " [" + clientName.trim() + "]";
approval.setApprovalSubject("[" + request.getOrg().getOrgName() + "]" + clientNamePart
+ " " + request.getType().getDescription() + " 승인");
for (PortalApprovalLineUser user : optLine.get().getPortalApprovalLineUsers()) {
this.addApprover(approval, user.getUser(), user.getApprovalOrder());
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.LocalDateTime;
@Service
@@ -104,9 +105,15 @@ public class AuthNumberServiceImpl implements AuthNumberService {
private void validateResendTime(String recipientKey) {
storage.getAuthNumber(recipientKey).ifPresent(existingAuth -> {
LocalDateTime now = LocalDateTime.now();
if (existingAuth.getExpiresAt().minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds).isAfter(now)) {
throw new AuthNumberException("잠시 후에 다시 시도해 주세요.");
LocalDateTime resendAvailableAt = existingAuth.getExpiresAt()
.minusSeconds(authNumberExpirationTime)
.plusSeconds(resendLimitSeconds);
if (resendAvailableAt.isAfter(now)) {
long remainingMillis = Duration.between(now, resendAvailableAt).toMillis();
long remainingSeconds = Math.max(1L, (remainingMillis + 999L) / 1000L);
throw new AuthNumberException(
String.format("인증번호 재발송 제한이 적용 중입니다. %d초 후 다시 시도해 주세요.",
remainingSeconds));
}
});
}
@@ -1,9 +1,12 @@
package com.eactive.apim.portal.apps.auth.twofactor;
import com.eactive.apim.portal.apps.session.service.UserSessionService;
import com.eactive.apim.portal.apps.user.facade.UserFacade;
import com.eactive.apim.portal.common.util.SecurityUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@@ -11,6 +14,8 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
/**
@@ -28,8 +33,14 @@ import javax.servlet.http.HttpSession;
@RequestMapping("/auth/stepup")
public class StepUpPasswordController {
/** 비밀번호 재확인 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) */
private static final int MAX_FAIL_COUNT = 5;
/** 연속 실패 횟수 세션 attribute 키 */
private static final String ATTR_FAIL_COUNT = "STEPUP_PW_CONFIRM_FAIL_COUNT";
private final UserFacade userFacade;
private final TwoFactorService twoFactorService;
private final UserSessionService userSessionService;
@GetMapping("/password")
public String page(@RequestParam(required = false) String returnUrl, Model model) {
@@ -44,7 +55,8 @@ public class StepUpPasswordController {
@PostMapping("/password")
public String verify(@RequestParam String currentPassword,
@RequestParam(required = false) String returnUrl,
HttpSession session, Model model) {
HttpSession session, HttpServletRequest request, HttpServletResponse response,
Model model) {
String path = pathOf(returnUrl);
if (!StepUpProtectedPaths.isPasswordGated(path)) {
return "redirect:/";
@@ -52,16 +64,34 @@ public class StepUpPasswordController {
String loginId = SecurityUtil.getCurrentLoginId();
if (userFacade.verifyCurrentPassword(loginId, currentPassword)) {
// 확인 성공 → 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
// 확인 성공 → 실패 카운트 초기화, 해당 경로 통과권 발급 후 원경로(화이트리스트 경로)로만 복귀
session.removeAttribute(ATTR_FAIL_COUNT);
twoFactorService.grantStepUpPass(session, path);
return "redirect:" + path;
}
model.addAttribute("error", "현재 비밀번호가 일치하지 않습니다.");
// 연속 실패 카운트 증가. 임계치 초과 시 세션을 강제 종료(로그아웃)한다(무차별 대입 방어).
int failCount = incrementFailCount(session);
if (failCount >= MAX_FAIL_COUNT) {
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
return "redirect:/login?pwFailExceeded=1";
}
model.addAttribute("error",
"현재 비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
model.addAttribute("returnUrl", path);
return "apps/auth/stepupPassword";
}
private static int incrementFailCount(HttpSession session) {
Integer count = (Integer) session.getAttribute(ATTR_FAIL_COUNT);
int next = (count == null ? 0 : count) + 1;
session.setAttribute(ATTR_FAIL_COUNT, next);
return next;
}
/** 쿼리스트링을 제외한 경로 부분만 추출(화이트리스트 검증용, open redirect 방지) */
private static String pathOf(String url) {
if (url == null) {
@@ -61,4 +61,23 @@ public class PartnershipApplicationController {
return "redirect:/partnership";
}
/**
* 본인이 작성한 피드백/개선요청 1건 삭제.
* 목록(최근 3건)의 삭제 버튼이 항목별 form 을 POST 한다 — 등록과 동일하게 폼 전송 + flash 메시지 방식.
*/
@PostMapping("/{id}/delete")
public String deleteMyPartnershipApplication(@PathVariable String id, RedirectAttributes redirectAttributes) {
if (!SecurityUtil.isAuthenticated()) {
return "redirect:/login?reason=auth&redirect=/partnership";
}
try {
partnershipApplicationFacade.deleteMyApplication(id);
redirectAttributes.addFlashAttribute("success", "피드백/개선요청이 삭제되었습니다.");
} catch (IllegalArgumentException e) {
redirectAttributes.addFlashAttribute("error", e.getMessage());
}
return "redirect:/partnership";
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.repository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
@@ -18,7 +19,19 @@ public interface PartnershipApplicationRepository extends JpaRepository<Partners
*/
List<PartnershipApplication> findTop3ByCreatedByOrderByCreatedDateDesc(String createdBy);
/**
* 본인 글 삭제용 단건 조회. id 만으로 찾지 않고 createdBy 를 함께 걸어
* 남의 글 id 를 넣어도 조회되지 않게 한다(소유자 검증을 쿼리 단계에서 강제).
*/
Optional<PartnershipApplication> findByIdAndCreatedBy(String id, String createdBy);
/** createdBy = PortalUser.id (평문 등가 조회 가능한 이유는 위와 동일). */
@Transactional
long deleteByCreatedBy(String createdBy);
/**
* test-cleanup 전용 — 특정 작성자의 글 중 제목이 지정 접두사로 시작하는 것만 조회한다.
* (bizSubject 는 암호화 컬럼이 아니라 LIKE 조회가 가능하다.)
*/
List<PartnershipApplication> findAllByCreatedByAndBizSubjectStartingWith(String createdBy, String bizSubjectPrefix);
}
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
*/
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
/**
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
*/
void deleteMyApplication(String id);
}
@@ -20,6 +20,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
@Service
@RequiredArgsConstructor
@@ -74,4 +75,23 @@ public class PartnershipApplicationFacadeImpl implements PartnershipApplicationF
List<PartnershipApplication> recent = partnershipApplicationService.findRecentByCreatedBy(user.getId());
return partnershipApplicationMapper.toSummaryDtoList(recent);
}
@Override
public void deleteMyApplication(String id) {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
if (user == null) {
throw new IllegalArgumentException("로그인이 필요합니다.");
}
// id 만으로 조회하지 않고 createdBy 를 함께 걸어 타인 글 삭제를 원천 차단한다.
PartnershipApplication target = partnershipApplicationService
.findOwnedByCreatedBy(id, user.getId())
.orElseThrow(() -> new IllegalArgumentException("삭제할 수 있는 피드백/개선요청이 아닙니다."));
// 첨부파일도 함께 정리한다(관리자 삭제 PortalPartnershipManService.delete 와 동일 처리).
if (StringUtils.isNotBlank(target.getFileId())) {
fileService.deleteFile(target.getFileId());
}
partnershipApplicationService.deletePartnershipApplication(target);
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.partnership.service;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -30,4 +31,16 @@ public class PartnershipApplicationService {
public List<PartnershipApplication> findRecentByCreatedBy(String createdBy) {
return partnershipApplicationRepository.findTop3ByCreatedByOrderByCreatedDateDesc(createdBy);
}
/**
* 작성자 본인 글 단건 조회. id 와 createdBy 를 함께 조건으로 걸어 타인 글은 조회되지 않는다.
*/
@Transactional(readOnly = true)
public Optional<PartnershipApplication> findOwnedByCreatedBy(String id, String createdBy) {
return partnershipApplicationRepository.findByIdAndCreatedBy(id, createdBy);
}
public void deletePartnershipApplication(PartnershipApplication partnershipApplication) {
partnershipApplicationRepository.delete(partnershipApplication);
}
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.apps.community.qna.repository;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.eai.rms.data.EMSDataSource;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
@@ -17,4 +18,7 @@ public interface InquiryRepository extends JpaRepository<Inquiry, String>, JpaSp
@Transactional
long deleteByInquirer_Id(String inquirerId);
/** 4010 테스트 cleanup 전용 — 작성자 + 제목 접두사로 테스트 문의글만 좁혀 조회한다. */
List<Inquiry> findAllByInquirer_IdAndInquirySubjectStartingWith(String inquirerId, String subjectPrefix);
}
@@ -9,7 +9,7 @@ import lombok.NoArgsConstructor;
* 인덱스 페이지 하단 통계 DTO
* - API 활용 기업: 법인으로 등록된 수의 합계 (정상 상태)
* - 서비스 이용 수: 전체 법인이 생성한 앱의 합계 (이용 가능 상태)
* - API 이용 건수: 전체 법인이 생성한 앱의 API 수의 합계 (이용 가능 상태)
* - API 이용 건수 (월누적): 전체 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건수
*/
@Data
@Builder
@@ -37,8 +37,8 @@ public class IndexStatisticsDTO {
private int activeAppCount;
/**
* API 이용 건수
* 정상 상태 법인이 생성한 이용 가능 앱에 연결된 API의 총
* API 이용 건수 (월누적)
* 정상 상태 법인 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 API 호출 건
*/
private int totalApiCount;
}
@@ -1,14 +1,20 @@
package com.eactive.apim.portal.apps.main.service;
import com.eactive.apim.portal.app.entity.Credential;
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsDayRepository;
import com.eactive.apim.gateway.data.statistics.repository.ApiStatsHourRepository;
import com.eactive.apim.gateway.data.statistics.repository.GwAuthClientRepository;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apps.main.dto.IndexStatisticsDTO;
import com.eactive.apim.portal.apps.statistics.dto.ApiStatisticsSummaryDto;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.ApprovalStatus;
import com.eactive.apim.portal.portalorg.entity.PortalOrgEnums.OrgStatus;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -31,6 +37,9 @@ public class IndexStatisticsService {
private final PortalOrgRepository portalOrgRepository;
private final CredentialRepository credentialRepository;
private final GwAuthClientRepository gwAuthClientRepository;
private final ApiStatsDayRepository apiStatsDayRepository;
private final ApiStatsHourRepository apiStatsHourRepository;
private final PortalPropertyService portalPropertyService;
// 캐시된 통계 데이터
@@ -91,11 +100,13 @@ public class IndexStatisticsService {
// 3. 서비스 이용 수: 정상 기관의 이용 가능 앱 수
activeAppCount = (int) credentialRepository.countActiveAppsByOrgIds(activeOrgIds);
// 4. API 이용 건수: 정상 기관의 이용 가능 앱에 연결된 API
List<Credential> activeApps = credentialRepository.findActiveAppsByOrgIds(activeOrgIds);
totalApiCount = activeApps.stream()
.mapToInt(credential -> credential.getApiList() != null ? credential.getApiList().size() : 0)
.sum();
// 4. API 이용 건수 (월누적): 정상 기관 소속 게이트웨이 클라이언트의 이번 달 1일~오늘 누적 호출 건
List<String> clientIds = gwAuthClientRepository.findClientIdsByOrgIdIn(activeOrgIds).stream()
.filter(id -> id != null && !id.trim().isEmpty())
.collect(Collectors.toList());
if (!clientIds.isEmpty()) {
totalApiCount = (int) getMonthlyApiCallCount(clientIds);
}
}
cachedStatistics = IndexStatisticsDTO.builder()
@@ -125,6 +136,30 @@ public class IndexStatisticsService {
}
}
/**
* 게이트웨이 클라이언트들의 이번 달 1일~오늘 누적 API 호출 건수(TOTAL_CNT 합산).
* 어제까지는 API_STATS_DAY(일별 집계), 오늘은 아직 DAY 미집계이므로 API_STATS_HOUR로 합산한다.
* ({@link com.eactive.apim.portal.apps.statistics.service.ApiStatisticsService#combineDayAndToday}와 동일 패턴)
*/
private long getMonthlyApiCallCount(List<String> clientIds) {
LocalDate today = LocalDate.now();
LocalDate monthStart = today.withDayOfMonth(1);
LocalDate dayEnd = today.minusDays(1);
long total = 0L;
if (!monthStart.isAfter(dayEnd)) {
total += nz(apiStatsDayRepository.findSummary(clientIds, monthStart, dayEnd).getTotalCount());
}
ApiStatisticsSummaryDto todaySummary = apiStatsHourRepository.findSummary(
clientIds, today.atStartOfDay(), today.atTime(LocalTime.MAX));
total += nz(todaySummary.getTotalCount());
return total;
}
private static long nz(Long value) {
return value != null ? value : 0L;
}
/**
* PortalProperty에서 통계 노출 여부 조회
* 프로퍼티가 없으면 기본값 "Y"를 DB에 저장 후 반환
@@ -35,8 +35,8 @@ public class UserRegisterRestController {
}
@PostMapping("/check_password_match")
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String password2) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, password2));
public ResponseEntity<ValidationResponse> checkPasswordMatch(@RequestParam String password, @RequestParam String confirmPassword) {
return ResponseEntity.ok(userRegisterFacade.checkPasswordMatch(password, confirmPassword));
}
@PostMapping("/register/confirm_password")
@@ -10,7 +10,7 @@ import org.hibernate.validator.constraints.NotEmpty;
@AuthNumberMatch(recipient = "loginId", authField = "authNumber")
@PasswordMatch(input = "password", confirm = "password2")
@PasswordMatch(input = "password", confirm = "confirmPassword")
@Data
@PasswordRule(password = "password", loginId = "loginId", mobile = "mobileNumber")
public class PortalUserRegistrationDTO {
@@ -31,8 +31,6 @@ public class PortalUserRegistrationDTO {
*/
private String password;
private String password2;
@CellPhone
private String mobileNumber;
@@ -140,11 +140,11 @@ public class UserFacadeImpl implements UserFacade {
public void withdrawUser(String userId, String withdrawalReason) {
PortalUser user = portalUserService.findById(userId);
// 법인 관리자 탈퇴 제한
// 법인 관리자는 권한 이관 전 탈퇴할 수 없다.
if (user.getRoleCode() == PortalUserEnums.RoleCode.ROLE_CORP_MANAGER) {
if(portalUserService.checkOrgHasOtherUsers(user.getPortalOrg())){
throw new IllegalArgumentException("법인 관리자권한을 다른 개발자에게 위임하신 후 탈퇴가 가능합니다.");
}
throw new IllegalArgumentException(
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.");
}
// 약관 동의 정보 삭제
@@ -19,7 +19,7 @@ public interface UserRegisterFacade {
ValidationResponse checkPassword(String password, String loginId, String mobileNumber);
ValidationResponse checkPasswordMatch(String password, String password2);
ValidationResponse checkPasswordMatch(String password, String confirmPassword);
ValidationResponse verifyPassword(String loginId, String confirmPassword);
@@ -94,8 +94,8 @@ public class UserRegisterFacadeImpl implements UserRegisterFacade {
}
@Override
public ValidationResponse checkPasswordMatch(String password, String password2) {
boolean isMatch = password.equals(password2);
public ValidationResponse checkPasswordMatch(String password, String confirmPassword) {
boolean isMatch = password.equals(confirmPassword);
String message = isMatch ? "비밀번호가 일치합니다." : "비밀번호가 일치하지 않습니다.";
return new ValidationResponse(isMatch, message);
}
@@ -34,7 +34,8 @@ public class PasswordService {
.orElseThrow(() -> new IllegalArgumentException("해당 사용자를 찾을 수 없습니다."));
validatePasswordUpdate(user, newPassword, confirmPassword);
checkPasswordHistory(user.getLoginId(), newPassword);
// PTL_USER_PASSWORD_HISTORY.USER_ID 에는 loginId가 아닌 PortalUser.id가 저장된다.
checkPasswordHistory(user.getId(), newPassword);
List<UserPasswordHistory> histories = passwordHistoryRepository.findRecentPasswordsByUserId(user.getId());
if(histories.isEmpty()) {
@@ -85,6 +86,17 @@ public class PasswordService {
return result;
}
/**
* {@link #updatePassword} 를 거치지 않고 비밀번호 해시를 직접 바꾸는 지점(예: 비밀번호 초기화로
* 임시 비밀번호 발급 — PortalUserAuthService.resetPassword)이 <b>덮어쓰기 직전</b>에 호출해,
* 지금 버려지는 비밀번호를 이력에 남긴다. 이걸 빼먹으면 재사용 금지(최근 5회) 검증이 그 비밀번호를
* 전혀 모른 채로 남아 있어, 초기화 이후 바로 예전 비밀번호로 되돌리는 게 허용되는 보안 허점이 된다.
*/
@Transactional
public void recordExternalPasswordChange(String userId, String previousPasswordHash) {
savePasswordHistory(userId, previousPasswordHash);
}
private void checkPasswordHistory(String userId, String newPassword) {
List<UserPasswordHistory> passwordHistories = passwordHistoryRepository.findRecentPasswordsByUserId(userId);
@@ -56,6 +56,7 @@ public class PortalUserAuthService implements UserDetailsService {
private final MessageRequestRepository messageRequestRepository;
private final EncryptionUtil encryptionUtil;
private final LoginFinalizer loginFinalizer;
private final PasswordService passwordService;
@Override
@Transactional(noRollbackFor = UsernameNotFoundException.class)
@@ -66,7 +67,9 @@ public class PortalUserAuthService implements UserDetailsService {
PortalUser portalUser = findByEmailAddr(normalizedUsername);
return buildAuthenticatedUser(portalUser);
} catch (UserNotFoundException e) {
throw new UsernameNotFoundException("입력하신 사용자 정보가 올바르지 않습니다. 다시 확인해 주세요.");
// 계정 열거(user enumeration) 공격 방지: 비밀번호 불일치(BadCredentialsException, PortalAuthenticationManager)와
// 동일한 문구를 사용해 아이디 존재 여부가 노출되지 않도록 한다.
throw new UsernameNotFoundException("아이디 또는 비밀번호가 일치하지 않습니다.");
}
}
@@ -162,6 +165,10 @@ public class PortalUserAuthService implements UserDetailsService {
.orElseThrow(() -> new UserNotFoundException("일치하는 사용자 정보를 찾을 수 없습니다."));
String tempPassword = EncryptionUtil.generateNewPassword();
// 지금 버려지는(임시 비밀번호로 교체되는) 비밀번호를 이력에 남긴다 — 안 남기면 재사용 금지
// (최근 5회) 검증이 이 비밀번호를 모른 채로 남아, 초기화 직후 바로 예전 비밀번호로 되돌리는
// 것이 허용되는 보안 허점이 생긴다.
passwordService.recordExternalPasswordChange(portalUser.getId(), portalUser.getPasswordHash());
portalUser.setPasswordHash(passwordEncoder.encode(tempPassword));
// 임시 비밀번호 발급 → 변경일을 null 로 초기화해 로그인 시 강제 비밀번호 변경을 유도한다
// (LoginFinalizer.applyPostLoginState 의 passwordChangeDate == null 분기)
@@ -133,6 +133,61 @@ public class GlobalControllerAdvice {
"Portal", "customer.center.contact", "1588-3388", "고객센터 연락처");
}
/**
* 메인 페이지 본문에 노출되는 브랜드명. PortalProperty(Portal/brand.name)에서 조회.
* 로고 이미지(alt 텍스트)는 별도이며 이 값의 영향을 받지 않는다.
*/
@ModelAttribute("brandName")
public String brandName() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.name", "DJBank", "메인 페이지 브랜드명 표기");
}
/**
* brandName 뒤에 바로 붙는 주격 조사(이/가). 받침 유무에 따라 관리자가 값을 바꿔도 문법이 깨지지 않도록 계산한다.
*/
@ModelAttribute("brandNameJosaGa")
public String brandNameJosaGa() {
return hasBatchim(brandName()) ? "" : "";
}
/**
* brandName 뒤에 바로 붙는 보조사(은/는).
*/
@ModelAttribute("brandNameJosaEun")
public String brandNameJosaEun() {
return hasBatchim(brandName()) ? "" : "";
}
/**
* 헤더(GNB) 로고 이미지 경로. PortalProperty(Portal/brand.logo.header.path)에서 조회.
*/
@ModelAttribute("brandLogoHeaderPath")
public String brandLogoHeaderPath() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.logo.header.path", "/img/logo/logo-djb.png", "헤더(GNB) 로고 이미지 경로");
}
/**
* 푸터 로고 이미지 경로. PortalProperty(Portal/brand.logo.footer.path)에서 조회.
*/
@ModelAttribute("brandLogoFooterPath")
public String brandLogoFooterPath() {
return portalPropertyService.getOrCreateProperty(
"Portal", "brand.logo.footer.path", "/img/logo/logo-jjb.png", "푸터 로고 이미지 경로");
}
private boolean hasBatchim(String word) {
if (word == null || word.isEmpty()) {
return false;
}
char last = word.charAt(word.length() - 1);
if (last >= 0xAC00 && last <= 0xD7A3) {
return (last - 0xAC00) % 28 != 0;
}
return "AEIOUaeiou".indexOf(last) < 0;
}
/**
* 푸터 관련 사이트 셀렉트 라벨. PortalProperty(Portal/footer.related-sites.label)에서 조회.
*/
@@ -57,6 +57,12 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Value("${app.resource-caching.enabled:false}")
private boolean resourceCachingEnabled;
// 정적자원 서빙 루트(application.yml: app.web-resources.static-base).
// 기본은 classpath(빌드 산출물), local_rinjaemac 프로파일은 file:${user.dir}/src/main/resources/static/
// 로 오버라이드해 소스 편집이 재빌드 없이 즉시 반영되도록 한다.
@Value("${app.web-resources.static-base:classpath:/static/}")
private String staticBase;
public PortalConfigWebDispatcherServlet(Environment environment,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorService twoFactorService,
com.eactive.apim.portal.apps.auth.twofactor.TwoFactorProperties twoFactorProperties,
@@ -151,15 +157,15 @@ public class PortalConfigWebDispatcherServlet implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
addStaticResourceHandler(registry, "/css/**", "/css/", "classpath:/static/css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", "classpath:/static/webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", "classpath:/static/font/");
addStaticResourceHandler(registry, "/html/**", "/html/", "classpath:/static/html/");
addStaticResourceHandler(registry, "/images/**", "/images/", "classpath:/static/images/");
addStaticResourceHandler(registry, "/img/**", "/img/", "classpath:/static/img/");
addStaticResourceHandler(registry, "/js/**", "/js/", "classpath:/static/js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", "classpath:/static/plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", "classpath:/static/favicon.ico");
addStaticResourceHandler(registry, "/css/**", "/css/", staticBase + "css/");
addStaticResourceHandler(registry, "/webfonts/**", "/webfonts/", staticBase + "webfonts/");
addStaticResourceHandler(registry, "/font/**", "/font/", staticBase + "font/");
addStaticResourceHandler(registry, "/html/**", "/html/", staticBase + "html/");
addStaticResourceHandler(registry, "/images/**", "/images/", staticBase + "images/");
addStaticResourceHandler(registry, "/img/**", "/img/", staticBase + "img/");
addStaticResourceHandler(registry, "/js/**", "/js/", staticBase + "js/");
addStaticResourceHandler(registry, "/plugins/**", "/plugins/", staticBase + "plugins/");
addStaticResourceHandler(registry, "/favicon.ico", "/favicon.ico", staticBase + "favicon.ico");
}
/**
@@ -21,4 +21,8 @@ public interface InquiryCommentRepository extends JpaRepository<InquiryComment,
@Transactional
long deleteByInquiry_Inquirer_Id(String inquirerId);
/** 4010 테스트 cleanup 전용 — 삭제 대상 문의글 id 목록에 딸린 댓글을 함께 지운다. */
@Transactional
long deleteByInquiry_IdIn(Collection<String> inquiryIds);
}
@@ -5,11 +5,14 @@ import com.eactive.apim.portal.common.util.IpAddressMatcher;
import com.eactive.apim.portal.djb.testcleanup.service.OrphanCleanupService;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupResult;
import com.eactive.apim.portal.djb.testcleanup.service.TestCleanupService;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portalproperty.service.PortalPropertyService;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -17,14 +20,17 @@ import org.apache.commons.lang3.StringUtils;
import org.springframework.context.annotation.Profile;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Playwright E2E 테스트 정리용 내부 API — local(개인 dev 프로필, 항상 {@code dev} 동반 활성화)과
* 배포된 dev 서버 양쪽에서만 존재한다({@code @Profile("dev")}, stage/prod 는 빈 자체가 없어 404).
* Playwright E2E 테스트 정리용 내부 API — 전용 {@code playwright} 프로필에서만 존재한다
* ({@code @Profile("playwright")}). 개인 local 프로필·배포된 dev 서버가 각자
* {@code spring.profiles.include: playwright} 로 이 프로필을 동반 활성화하며, stage/prod 는
* 이를 포함하지 않아 빈 자체가 없어 404.
*
* <p>가드는 {@link com.eactive.apim.portal.djb.menu.MenuInternalController} 와 동일하게 두 겹이다.</p>
* <ol>
@@ -41,7 +47,7 @@ import org.springframework.web.bind.annotation.RestController;
* 'http://127.0.0.1:39130/internal/test-cleanup/org?compRegNo=1234567890'</pre>
*/
@Slf4j
@Profile("dev")
@Profile("playwright")
@RestController
@RequestMapping("/internal/test-cleanup")
@RequiredArgsConstructor
@@ -103,6 +109,257 @@ public class TestCleanupInternalController {
return ResponseEntity.ok(body);
}
/**
* Playwright 실행 전 동일 이메일 계정의 존재 여부를 확인한다. 조회 전용이며 삭제하지 않는다.
*/
@GetMapping("/user/exists")
public ResponseEntity<Map<String, Object>> checkUserExists(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.checkUserExists(email);
log.info("테스트 정리(user exists) 조회 - email: {}, found: {}, from: {}",
email, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("userFound", result.isFound());
return ResponseEntity.ok(body);
}
/**
* 2000 실행 전 법인 계정의 실제 선행 상태를 조회한다. 조회 전용이며 cleanup을 수행하지 않는다.
* API 신청 권한의 최종 렌더링 여부는 보안 권한 매핑에 따르므로, E2E는 이 응답으로 데이터 선행조건을
* 확인한 다음 /clients 화면의 생성 버튼 노출까지 함께 검증한다.
*/
@GetMapping("/user/status")
public ResponseEntity<Map<String, Object>> getUserStatus(
@RequestParam String email, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email)) {
return badRequest("email 은 필수입니다.");
}
Optional<PortalUser> userOpt = testCleanupService.findUserForStatus(email);
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userFound", userOpt.isPresent());
if (userOpt.isPresent()) {
PortalUser user = userOpt.get();
PortalOrg org = user.getPortalOrg();
body.put("userId", user.getId());
body.put("roleCode", user.getRoleCode() == null ? null : user.getRoleCode().name());
body.put("userStatus", user.getUserStatus() == null ? null : user.getUserStatus().name());
body.put("userApprovalStatus", user.getApprovalStatus() == null ? null : user.getApprovalStatus().name());
body.put("authCompletedYn", user.getAuthCompletedYn());
body.put("orgId", org == null ? null : org.getId());
body.put("orgStatus", org == null || org.getOrgStatus() == null ? null : org.getOrgStatus().name());
body.put("orgApprovalStatus", org == null || org.getApprovalStatus() == null
? null : org.getApprovalStatus().name());
}
log.info("테스트 정리(user status) 조회 - email: {}, found: {}, from: {}",
email, userOpt.isPresent(), request.getRemoteAddr());
return ResponseEntity.ok(body);
}
/**
* 2000 재실행 전 동일 이름으로 남은 테스트 클라이언트 신청만 취소한다.
* 대상은 {@code 단위테스트앱-} 접두사와 이메일 소속 법인으로 이중 한정한다.
*/
@PostMapping("/app-request")
public ResponseEntity<Map<String, Object>> cancelPendingTestAppRequests(
@RequestParam String email, @RequestParam String clientName, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(clientName)) {
return badRequest("email 과 clientName 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.cancelPendingTestAppRequests(email, clientName);
log.info("테스트 정리(app request) 실행 - email: {}, clientName: {}, found: {}, from: {}",
email, clientName, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("clientName", clientName);
body.put("appRequestFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4020 재실행 전, 해당 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다.
* 대상은 이메일(작성자)과 {@code 단위테스트*} 제목 접두사로 이중 한정한다.
*/
@PostMapping("/partnership")
public ResponseEntity<Map<String, Object>> deletePartnershipApplications(
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
return badRequest("email 과 subjectPrefix 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deletePartnershipApplicationsByEmail(email, subjectPrefix);
log.info("테스트 정리(partnership) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("subjectPrefix", subjectPrefix);
body.put("partnershipFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4010 재실행 전(또는 종료 후 최종 정리), 해당 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
* 대상은 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사로 이중 한정한다.
*/
@PostMapping("/inquiry")
public ResponseEntity<Map<String, Object>> deleteInquiries(
@RequestParam String email, @RequestParam String subjectPrefix, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(subjectPrefix)) {
return badRequest("email 과 subjectPrefix 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteInquiriesByEmail(email, subjectPrefix);
log.info("테스트 정리(inquiry) 실행 - email: {}, subjectPrefix: {}, found: {}, from: {}",
email, subjectPrefix, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("subjectPrefix", subjectPrefix);
body.put("inquiryFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 4010 실행 전, 지정 이메일 계정을 관리자 이메일로 조회한 법인의 ROLE_CORP_USER(법인개발자)로 준비한다.
* 이미 존재하면 재소속+비밀번호 재설정(heal), 없으면 신규 생성(create) — 두 경우 모두 응답한 비밀번호로
* 곧바로 로그인 가능한 상태가 된다.
*/
@PostMapping("/corp-developer")
public ResponseEntity<Map<String, Object>> ensureCorpDeveloper(
@RequestParam String managerEmail, @RequestParam String email, @RequestParam String password,
@RequestParam String mobile, @RequestParam String userName, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(managerEmail) || StringUtils.isBlank(email) || StringUtils.isBlank(password)
|| StringUtils.isBlank(mobile) || StringUtils.isBlank(userName)) {
return badRequest("managerEmail, email, password, mobile, userName 은 모두 필수입니다.");
}
TestCleanupResult result = testCleanupService.ensureTestCorpDeveloper(managerEmail, email, password, mobile, userName);
boolean created = result.getDeletedCounts().getOrDefault("PTL_USER_CREATED", 0L) > 0;
log.info("테스트 정리(corp-developer) 실행 - managerEmail: {}, email: {}, created: {}, from: {}",
managerEmail, email, created, request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("managerEmail", managerEmail);
body.put("email", email);
body.put("userId", result.getTargetId());
body.put("created", created);
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 그대로). 화면 가입 절차 없이 즉시 로그인
* 가능한 값으로 되돌리는 용도 — 공용 시드 계정의 비밀번호가 정책과 안 맞게 바뀐 경우 등.
*/
@PostMapping("/password")
public ResponseEntity<Map<String, Object>> resetPassword(
@RequestParam String email, @RequestParam String password, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(email) || StringUtils.isBlank(password)) {
return badRequest("email 과 password 는 필수입니다.");
}
TestCleanupResult result = testCleanupService.resetTestPassword(email, password);
log.info("테스트 정리(password) 실행 - email: {}, found: {}, from: {}",
email, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("email", email);
body.put("userFound", result.isFound());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 남아 있는 초대 레코드를 삭제한다. 1020 재실행 전 초대중 중복을 정리하는 용도다.
*/
@PostMapping("/invitation")
public ResponseEntity<Map<String, Object>> deleteInvitations(
@RequestParam String mobile, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(mobile)) {
return badRequest("mobile 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.deleteInvitationsByMobile(mobile);
log.info("테스트 정리(invitation) 실행 - mobile: {}, found: {}, from: {}",
mobile, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("mobile", mobile);
body.put("invitationFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
/**
* 휴대폰 번호로 찾은 계정을 법인 소속에서 제외한다. 계정 자체는 삭제하지 않는다.
*/
@PostMapping("/membership")
public ResponseEntity<Map<String, Object>> detachMembership(
@RequestParam String mobile, HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
if (guardFailure != null) {
return guardFailure;
}
if (StringUtils.isBlank(mobile)) {
return badRequest("mobile 은 필수입니다.");
}
TestCleanupResult result = testCleanupService.detachUsersFromOrgByMobile(mobile);
log.info("테스트 정리(membership) 실행 - mobile: {}, found: {}, from: {}",
mobile, result.isFound(), request.getRemoteAddr());
Map<String, Object> body = baseBody();
body.put("mobile", mobile);
body.put("membershipFound", result.isFound());
body.put("deletedCounts", result.getDeletedCounts());
return ResponseEntity.ok(body);
}
@PostMapping("/orphans")
public ResponseEntity<Map<String, Object>> cleanOrphans(HttpServletRequest request) {
ResponseEntity<Map<String, Object>> guardFailure = checkGuards(request);
@@ -11,11 +11,11 @@ import org.springframework.transaction.annotation.Transactional;
/**
* Playwright 반복 실행으로 쌓인, 더 이상 유효한 PTL_ORG/PTL_USER 를 참조하지 않는 잔존(고아) 행을
* 13개 테이블에서 스윕 삭제한다. local(dev 동반 활성화)·dev 서버에서만 동작한다({@link TestCleanupService}
* 13개 테이블에서 스윕 삭제한다. 전용 {@code playwright} 프로필에서만 동작한다({@link TestCleanupService}
* 참고). 대상이 "이미 사라진 org/user"뿐이라 살아있는 테스트 데이터를 건드릴 위험이 없어 파라미터가 없다.
*/
@Slf4j
@Profile("dev")
@Profile("playwright")
@Service
@Transactional
public class OrphanCleanupService {
@@ -9,7 +9,7 @@ import org.springframework.stereotype.Repository;
* 독립 엔티티가 없어 JPA 파생 삭제 메서드를 쓸 수 없는 테이블(PTL_CREDENTIAL_API, PTL_WEBHOOK_SEND_LOG) 전용,
* 특정 org 소유분만 지우는 삭제. EMS 가 {@code @Primary} 이므로 기본 EntityManager 를 그대로 쓴다.
*/
@Profile("dev")
@Profile("playwright")
@Repository
class TestCleanupNativeQueries {
@@ -1,41 +1,59 @@
package com.eactive.apim.portal.djb.testcleanup.service;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.statemachine.ProcessingState;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.user.dto.PortalUserRegistrationDTO;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.common.util.PhoneNumberUtil;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.partnershipapplication.entity.PartnershipApplication;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import com.eactive.apim.portal.portaluser.repository.UserRoleHistoryRepository;
import com.eactive.apim.portal.qna.entity.Inquiry;
import com.eactive.apim.portal.user.repository.UserLogRepository;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Profile;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Playwright E2E 테스트가 반복 실행되며 쌓이는 테스트 법인(PTL_ORG)·계정(PTL_USER)과
* 그에 딸린 데이터를 하드 삭제한다. local(dev 동반 활성화)·dev 서버에서만 존재하는 빈이며,
* stage/prod 는 {@code @Profile("dev")} 로 빈 자체가 등록되지 않는다.
* 그에 딸린 데이터를 하드 삭제한다. 전용 {@code playwright} 프로필에서만 존재하는 빈이며,
* local/dev 프로필이 {@code spring.profiles.include: playwright} 로 동반 활성화한다.
* stage/prod 는 이를 포함하지 않아 {@code @Profile("playwright")} 로 빈 자체가 등록되지 않는다.
*
* <p>모든 대상 테이블이 EMS 스키마(PTL_*)라 무지정 {@code @Transactional}(EMS, {@code @Primary})만 쓴다.</p>
*/
@Slf4j
@Profile("dev")
@Profile("playwright")
@Service
@RequiredArgsConstructor
@Transactional
@@ -52,11 +70,17 @@ public class TestCleanupService {
private final UserPasswordHistoryRepository userPasswordHistoryRepository;
private final UserLogRepository userLogRepository;
private final CredentialRepository credentialRepository;
private final AppRequestRepository appRequestRepository;
private final ApprovalService approvalService;
private final WebhookRequestRepository webhookRequestRepository;
private final WebhookRequestApiRepository webhookRequestApiRepository;
private final WebhookRequestEventRepository webhookRequestEventRepository;
private final WebhookService webhookService;
private final UserInvitationRepository userInvitationRepository;
private final FileService fileService;
private final TestCleanupNativeQueries nativeQueries;
private final PortalUserService portalUserService;
private final PasswordEncoder passwordEncoder;
/**
* 사업자등록번호로 법인을 찾아, 소속 계정 전원 + org 소유 CREDENTIAL/WEBHOOK + 법인 자체를 하드 삭제한다.
@@ -65,6 +89,12 @@ public class TestCleanupService {
assertNonProdProfile();
Optional<PortalOrg> orgOpt = portalOrgRepository.findByCompRegNo(compRegNo);
// 가입 화면은 000-00-00001처럼 입력받지만 DB에는 숫자만 저장되는 환경도 있다.
// cleanup API는 두 형식을 모두 받아 이전 E2E 실행 법인을 빠짐없이 정리해야 한다.
String digitsOnlyCompRegNo = compRegNo.replaceAll("\\D", "");
if (!orgOpt.isPresent() && !digitsOnlyCompRegNo.isEmpty() && !digitsOnlyCompRegNo.equals(compRegNo)) {
orgOpt = portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo);
}
if (!orgOpt.isPresent()) {
return TestCleanupResult.notFound();
}
@@ -72,6 +102,8 @@ public class TestCleanupService {
String orgId = org.getId();
TestCleanupResult result = TestCleanupResult.found(orgId);
result.put("PTL_USER_INVITATION", userInvitationRepository.deleteByOrgId(orgId));
List<PortalUser> users = portalUserRepository.findAllByPortalOrg_Id(orgId);
for (PortalUser user : users) {
result.merge(deleteUserCascadeInternal(user));
@@ -117,6 +149,257 @@ public class TestCleanupService {
return result;
}
/**
* 이메일 기준으로 테스트 계정 존재 여부만 조회한다. 삭제나 데이터 변경은 수행하지 않는다.
*/
@Transactional(readOnly = true)
public TestCleanupResult checkUserExists(String email) {
assertNonProdProfile();
return portalUserRepository.findPortalUserByEmailAddr(email)
.map(user -> TestCleanupResult.found(user.getId()))
.orElseGet(TestCleanupResult::notFound);
}
/**
* E2E 실행 가능 여부를 판단하기 위한 계정 조회다. 삭제나 상태 변경은 수행하지 않는다.
* 호출자는 법인 소속/승인/역할을 확인해 2000 선행 시나리오(1010/1011)가 갖춰졌는지 판단한다.
*/
@Transactional(readOnly = true)
public Optional<PortalUser> findUserForStatus(String email) {
assertNonProdProfile();
return portalUserRepository.findPortalUserByEmailAddr(email);
}
/**
* 동일 이름으로 재실행할 때 남은 테스트 APP 신청만 취소한다.
* 운영 데이터 오삭제를 막기 위해 {@code 단위테스트앱-} 접두사, 이메일의 법인 소속, 정확한 클라이언트명 세 조건을 모두 요구한다.
*/
public TestCleanupResult cancelPendingTestAppRequests(String email, String clientName) {
assertNonProdProfile();
if (clientName == null || !clientName.startsWith("단위테스트앱-")) {
throw new IllegalArgumentException("테스트 앱 이름(단위테스트앱-*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent() || userOpt.get().getPortalOrg() == null) {
return TestCleanupResult.notFound();
}
TestCleanupResult result = TestCleanupResult.notFound();
long cancelled = 0L;
for (AppRequest request : appRequestRepository.findAllByOrgAndClientName(userOpt.get().getPortalOrg(), clientName)) {
if (request.getApproval() == null || !(request.getApproval().getApprovalStatus() instanceof RequestedState
|| request.getApproval().getApprovalStatus() instanceof ProcessingState)) {
continue;
}
approvalService.cancelAppApproval(request);
result = TestCleanupResult.found(request.getId());
cancelled++;
}
result.put("PTL_APP_REQUEST_CANCELLED", cancelled);
return result;
}
/**
* 휴대폰 번호로 남은 초대 레코드를 전부 삭제한다. 1020 재실행 전 PENDING 초대 중복을 방지한다.
*/
public TestCleanupResult deleteInvitationsByMobile(String mobile) {
assertNonProdProfile();
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile == null) {
return TestCleanupResult.notFound();
}
long deleted = userInvitationRepository.deleteByInvitationMobile(normalizedMobile);
TestCleanupResult result = deleted > 0 ? TestCleanupResult.found(normalizedMobile) : TestCleanupResult.notFound();
result.put("PTL_USER_INVITATION", deleted);
return result;
}
/**
* 휴대폰 번호로 찾은 계정을 법인 소속에서만 제외해 개인회원으로 되돌린다.
* 1020이 수락/소속 제외 전에 중단된 경우, 1000 선행 개인회원은 보존하면서 재초대 가능 상태로 복구한다.
*/
public TestCleanupResult detachUsersFromOrgByMobile(String mobile) {
assertNonProdProfile();
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile == null) {
return TestCleanupResult.notFound();
}
TestCleanupResult result = TestCleanupResult.notFound();
for (PortalUser user : portalUserRepository.findAllByMobileNumber(normalizedMobile)) {
if (user.getPortalOrg() == null) {
continue;
}
user.setPortalOrg(null);
user.setRoleCode(RoleCode.ROLE_USER);
portalUserRepository.save(user);
result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_ORG_MEMBERSHIP", 1L);
}
return result;
}
/**
* 4020 재실행 전, 특정 계정이 남긴 테스트 피드백/개선요청 글만 삭제한다(첨부파일 포함).
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 단위테스트} 로 시작하는 제목 접두사를 모두 요구한다.
*/
public TestCleanupResult deletePartnershipApplicationsByEmail(String email, String subjectPrefix) {
assertNonProdProfile();
if (subjectPrefix == null || !subjectPrefix.startsWith("단위테스트")) {
throw new IllegalArgumentException("테스트 글 제목 접두사(단위테스트*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
List<PartnershipApplication> targets =
partnershipApplicationRepository.findAllByCreatedByAndBizSubjectStartingWith(user.getId(), subjectPrefix);
if (targets.isEmpty()) {
TestCleanupResult empty = TestCleanupResult.notFound();
empty.put("PTL_PARTNERSHIP_APPLICATION", 0L);
return empty;
}
long files = 0L;
for (PartnershipApplication target : targets) {
if (target.getFileId() != null && !target.getFileId().trim().isEmpty()) {
fileService.deleteFile(target.getFileId());
files++;
}
}
partnershipApplicationRepository.deleteAll(targets);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_PARTNERSHIP_APPLICATION", (long) targets.size());
result.put("PTL_FILE_INFO", files);
log.info("테스트 정리 - 피드백/개선요청 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
return result;
}
/**
* 4010 재실행 전(또는 종료 후 최종 정리) 특정 계정이 작성한 테스트 문의글만 삭제한다(댓글 포함).
* 운영 글 오삭제를 막기 위해 이메일(작성자)과 {@code 새글 작성 테스트} 로 시작하는 제목 접두사를 모두 요구한다.
* 답변완료(RESPONDED)로 전환된 문의는 포털 자가삭제(PENDING + 작성자 전용)가 막히므로, 4010 종료 시점의
* 최종 정리도 이 API를 재사용한다.
*/
public TestCleanupResult deleteInquiriesByEmail(String email, String subjectPrefix) {
assertNonProdProfile();
if (subjectPrefix == null || !subjectPrefix.startsWith("새글 작성 테스트")) {
throw new IllegalArgumentException("테스트 문의글 제목 접두사(새글 작성 테스트*)만 정리할 수 있습니다.");
}
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
List<Inquiry> targets =
inquiryRepository.findAllByInquirer_IdAndInquirySubjectStartingWith(user.getId(), subjectPrefix);
if (targets.isEmpty()) {
TestCleanupResult empty = TestCleanupResult.notFound();
empty.put("PTL_INQUIRY", 0L);
empty.put("PTL_INQUIRY_COMMENT", 0L);
return empty;
}
List<String> ids = targets.stream().map(Inquiry::getId).collect(Collectors.toList());
long comments = inquiryCommentRepository.deleteByInquiry_IdIn(ids);
inquiryRepository.deleteAll(targets);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY", (long) targets.size());
result.put("PTL_INQUIRY_COMMENT", comments);
log.info("테스트 정리 - 문의글 삭제 완료: email={}, prefix={}, count={}", email, subjectPrefix, targets.size());
return result;
}
/**
* 4010 재실행 안정성을 위한 테스트 전용 API. 지정 이메일 계정을 관리자 이메일로 조회한 법인의
* ROLE_CORP_USER(법인개발자)로 만든다 — 이미 존재하면(다른 시나리오/수작업이 소속을 해제했더라도)
* 재소속·비밀번호 재설정으로 로그인 가능 상태를 보장하고(heal), 계정이 없으면 새로 만든다(create).
* 계정 생성/소속 전환 로직은 초대 수락 경로({@link PortalUserService#registerInvitedUser},
* {@link PortalUserService#updateUserToCorpUser})를 그대로 재사용한다.
*/
public TestCleanupResult ensureTestCorpDeveloper(
String managerEmail, String email, String password, String mobile, String userName) {
assertNonProdProfile();
PortalUser manager = portalUserRepository.findPortalUserByEmailAddr(managerEmail)
.orElseThrow(() -> new IllegalArgumentException("관리자 계정을 찾을 수 없습니다: " + managerEmail));
PortalOrg org = manager.getPortalOrg();
if (org == null) {
throw new IllegalArgumentException("관리자 계정이 법인에 소속되어 있지 않습니다: " + managerEmail);
}
Optional<PortalUser> existing = portalUserRepository.findPortalUserByEmailAddr(email);
boolean created;
PortalUser user;
if (existing.isPresent()) {
user = existing.get();
portalUserService.updateUserToCorpUser(user, org.getId());
user.setUserStatus(PortalUserEnums.UserStatus.ACTIVE);
user.setApprovalStatus(PortalUserEnums.ApprovalStatus.COMPLETED);
user.setAccountLockYn("N");
user.setLoginFailureCount(0);
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
String normalizedMobile = PhoneNumberUtil.normalize(mobile);
if (normalizedMobile != null) {
user.setMobileNumber(normalizedMobile);
}
portalUserRepository.save(user);
created = false;
} else {
PortalUserRegistrationDTO dto = new PortalUserRegistrationDTO();
dto.setLoginId(email);
dto.setUserName(userName);
dto.setPassword(password);
dto.setMobileNumber(mobile);
user = portalUserService.registerInvitedUser(dto, org.getId());
created = true;
}
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_CREATED", created ? 1L : 0L);
result.put("PTL_USER_ATTACHED", created ? 0L : 1L);
log.info("테스트 정리 - 법인개발자 계정 준비 완료: email={}, orgId={}, created={}", email, org.getId(), created);
return result;
}
/**
* 테스트 계정의 비밀번호만 재설정한다(소속/역할/상태는 건드리지 않음). 계정이 아예 없으면 not-found.
* 4010 등에서 재사용하는 공용 계정(예: REG_CORP_LOGIN_ID)의 비밀번호가 클라이언트 정책(길이/복잡도)에
* 안 맞게 바뀌었을 때, 화면 가입 절차 없이 즉시 로그인 가능한 값으로 되돌리는 용도.
*/
public TestCleanupResult resetTestPassword(String email, String password) {
assertNonProdProfile();
Optional<PortalUser> userOpt = portalUserRepository.findPortalUserByEmailAddr(email);
if (!userOpt.isPresent()) {
return TestCleanupResult.notFound();
}
PortalUser user = userOpt.get();
user.setPasswordHash(passwordEncoder.encode(password));
user.setPasswordChangeDate(LocalDateTime.now());
user.setLoginFailureCount(0);
user.setAccountLockYn("N");
portalUserRepository.save(user);
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_USER_PASSWORD_RESET", 1L);
log.info("테스트 정리 - 비밀번호 재설정 완료: email={}", email);
return result;
}
private TestCleanupResult deleteUserCascadeInternal(PortalUser user) {
TestCleanupResult result = TestCleanupResult.found(user.getId());
result.put("PTL_INQUIRY_COMMENT", inquiryCommentRepository.deleteByInquiry_Inquirer_Id(user.getId()));
@@ -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.apps.session.service.UserSessionService;
import com.eactive.apim.portal.common.exception.UserErrorMessageResolver;
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
import com.eactive.apim.portal.common.util.SecurityUtil;
@@ -14,10 +15,15 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.annotation.Secured;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
@@ -50,10 +56,16 @@ public class WebhookController {
private static final int TOTAL_STEPS = 3;
/** 비밀번호 재인증 연속 실패 허용 횟수. 초과 시 세션 종료(강제 로그아웃) — StepUpPasswordController 답습. */
private static final int MAX_PW_FAIL_COUNT = 5;
/** 연속 실패 횟수 세션 attribute 키 */
private static final String ATTR_PW_FAIL_COUNT = "WEBHOOK_PW_CONFIRM_FAIL_COUNT";
private final WebhookService webhookService;
private final WebhookEventTypeProvider eventTypeProvider;
private final ApiServiceService apiServiceService;
private final AppServiceFacade appServiceFacade;
private final UserSessionService userSessionService;
@ModelAttribute("webhookRegistration")
public WebhookRegistrationDTO webhookRegistration() {
@@ -211,6 +223,7 @@ public class WebhookController {
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
mav.addObject("eventTypes", eventTypeProvider.getAll());
mav.addObject("userSecretSet", webhook.getUserSecretMasked() != null && !webhook.getUserSecretMasked().isEmpty());
addStepModel(mav, 1);
return mav;
}
@@ -297,33 +310,35 @@ public class WebhookController {
@PostMapping("/verify-secret")
@ResponseBody
public Map<String, Object> verifySecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> verifySecret(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
result.put("message", "등록된 Webhook이 없습니다.");
return result;
}
Long webhookId = webhook.get().getId();
result.put("success", true);
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), currentOrgId()));
result.put("secret", webhookService.getPlainSecret(webhookId, currentOrgId()));
result.put("userSecret", webhookService.getPlainUserSecret(webhookId, currentOrgId()));
return result;
}
@PostMapping("/regenerate-secret")
@ResponseBody
public Map<String, Object> regenerateSecret(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> regenerateSecret(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
@@ -338,13 +353,13 @@ public class WebhookController {
@PostMapping("/delete")
@ResponseBody
public Map<String, Object> delete(@RequestParam String password) {
Map<String, Object> result = new HashMap<>();
if (!verifyPassword(password)) {
result.put("success", false);
result.put("message", "비밀번호가 일치하지 않습니다.");
return result;
public Map<String, Object> delete(@RequestParam String password,
HttpSession session, HttpServletRequest request, HttpServletResponse response) {
Map<String, Object> failResult = checkPassword(password, session, request, response);
if (failResult != null) {
return failResult;
}
Map<String, Object> result = new HashMap<>();
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
if (!webhook.isPresent()) {
result.put("success", false);
@@ -376,6 +391,38 @@ public class WebhookController {
return appServiceFacade.verifyUserPassword(user, password);
}
/**
* 비밀번호 재인증 공통 체크. 성공(및 카운트 초기화) 시 {@code null}, 실패 시 즉시 응답할 결과 Map 을 반환한다.
* 연속 실패가 {@link #MAX_PW_FAIL_COUNT} 회 이상이면 세션을 강제 종료하고 {@code forceLogout=true} 를 담는다
* (무차별 대입 방어 — {@code StepUpPasswordController} 답습).
*/
private Map<String, Object> checkPassword(String password, HttpSession session,
HttpServletRequest request, HttpServletResponse response) {
if (verifyPassword(password)) {
session.removeAttribute(ATTR_PW_FAIL_COUNT);
return null;
}
Integer count = (Integer) session.getAttribute(ATTR_PW_FAIL_COUNT);
int failCount = (count == null ? 0 : count) + 1;
session.setAttribute(ATTR_PW_FAIL_COUNT, failCount);
Map<String, Object> result = new HashMap<>();
result.put("success", false);
if (failCount >= MAX_PW_FAIL_COUNT) {
userSessionService.removeSession(session.getId());
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
result.put("forceLogout", true);
result.put("message", "비밀번호 확인 5회 실패로 로그아웃되었습니다.");
log.warn("Webhook 비밀번호 재인증 5회 실패로 강제 로그아웃 loginId={}", SecurityUtil.getCurrentLoginId());
} else {
result.put("message",
"비밀번호가 일치하지 않습니다. (실패 " + failCount + "/" + MAX_PW_FAIL_COUNT + "회, 초과 시 자동 로그아웃됩니다)");
}
return result;
}
private String currentOrgId() {
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
@@ -0,0 +1,20 @@
package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 구독 대상 API ID/명칭 쌍. 화면 표시용(ID → API명 매핑은 {@code ApiService.findApisForApiIds} 사용).
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WebhookApiDTO implements Serializable {
private static final long serialVersionUID = 1L;
private String id;
private String name;
}
@@ -16,11 +16,15 @@ public class WebhookDTO implements Serializable {
private Long id;
private String targetUrl;
private String secretMasked;
private String userSecretMasked;
private String createdDate;
/** 구독 API ID 목록. */
/** 구독 API ID 목록(폼 prefill 등 내부용). */
private List<String> apiIds = new ArrayList<>();
/** 구독 API ID+명칭 목록(화면 표시용). */
private List<WebhookApiDTO> apis = new ArrayList<>();
/** 구독 EventType(코드+한글명) 목록. */
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
}
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.Pattern;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
@@ -27,6 +28,15 @@ public class WebhookRegistrationDTO implements Serializable {
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
private String targetUrl;
/**
* 사용자 지정 Secret(선택). Webhook 발송 시 HTTP 요청 헤더 값으로 그대로 echo 되므로
* 출력 가능 ASCII(0x20~0x7E)만 허용한다 — 헤더는 non-ASCII/개행을 담을 수 없다.
* 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조).
*/
@Length(max = 500, message = "값은 500자를 초과할 수 없습니다.")
@Pattern(regexp = "^[\\x20-\\x7E]*$", message = "영문·숫자·특수문자 등 ASCII 문자만 입력 가능합니다(한글 등 유니코드 문자는 사용할 수 없습니다).")
private String userSecret;
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
private List<String> eventTypes = new ArrayList<>();
@@ -13,6 +13,7 @@ import org.mapstruct.Mapping;
public interface WebhookMapper {
@Mapping(target = "secretMasked", ignore = true)
@Mapping(target = "userSecretMasked", ignore = true)
@Mapping(target = "apiIds", ignore = true)
@Mapping(target = "eventTypes", ignore = true)
WebhookDTO toDto(WebhookRequest entity);
@@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable {
@Column(name = "SECRET", length = 500)
private String secret;
/** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */
@Column(name = "USER_SECRET", length = 500)
private String userSecret;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@@ -1,5 +1,8 @@
package com.eactive.apim.portal.djb.webhook.service;
import com.eactive.apim.portal.apps.apis.dto.ApiSpecInfoDto;
import com.eactive.apim.portal.apps.apis.service.ApiService;
import com.eactive.apim.portal.djb.webhook.dto.WebhookApiDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookCreatedResult;
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
@@ -45,6 +48,7 @@ public class WebhookService {
private final WebhookSecretGenerator secretGenerator;
private final WebhookEventTypeProvider eventTypeProvider;
private final WebhookMapper webhookMapper;
private final ApiService apiService;
@Transactional(readOnly = true)
public boolean existsByOrg(String orgId) {
@@ -70,6 +74,7 @@ public class WebhookService {
request.setOrgId(orgId);
request.setTargetUrl(dto.getTargetUrl().trim());
request.setSecret(secret);
request.setUserSecret(normalizeUserSecret(dto.getUserSecret()));
WebhookRequest saved = requestRepository.save(request);
persistChildren(saved.getId(), dto);
@@ -79,12 +84,17 @@ public class WebhookService {
/**
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
* userSecret 은 공란으로 제출되면 기존 값을 유지한다(마스킹 표시라 재입력 없이는 원본을 알 수 없으므로).
*/
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
WebhookRequest request = loadOwned(id, orgId);
validate(dto);
request.setTargetUrl(dto.getTargetUrl().trim());
String userSecret = normalizeUserSecret(dto.getUserSecret());
if (userSecret != null) {
request.setUserSecret(userSecret);
}
requestRepository.save(request);
apiRepository.deleteByWebhookReqId(id);
@@ -128,6 +138,14 @@ public class WebhookService {
return loadOwned(id, orgId).getSecret();
}
/**
* 평문 사용자 지정 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
*/
@Transactional(readOnly = true)
public String getPlainUserSecret(Long id, String orgId) {
return loadOwned(id, orgId).getUserSecret();
}
// ----------------------------------------------------------------
private WebhookRequest loadOwned(Long id, String orgId) {
@@ -164,6 +182,14 @@ public class WebhookService {
}
}
private String normalizeUserSecret(String raw) {
if (raw == null) {
return null;
}
String trimmed = raw.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private List<String> dedup(List<String> values) {
if (values == null) {
return java.util.Collections.emptyList();
@@ -174,9 +200,18 @@ public class WebhookService {
private WebhookDTO toDetailDto(WebhookRequest request) {
WebhookDTO dto = webhookMapper.toDto(request);
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
dto.setUserSecretMasked(request.getUserSecret() == null || request.getUserSecret().isEmpty()
? "" : SECRET_MASK);
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId)
.collect(Collectors.toList());
dto.setApiIds(apiIds);
Map<String, String> apiNames = apiService.findApisForApiIds(apiIds).stream()
.collect(Collectors.toMap(ApiSpecInfoDto::getApiId, ApiSpecInfoDto::getApiName, (a, b) -> a));
dto.setApis(apiIds.stream()
.map(id -> new WebhookApiDTO(id, apiNames.getOrDefault(id, id)))
.collect(Collectors.toList()));
Map<String, String> names = eventTypeProvider.asMap();
+2
View File
@@ -2,6 +2,8 @@ spring:
config:
activate:
on-profile: dev
# playwright 프로필 합류는 base application.yml 의 spring.profiles.group 으로 처리한다.
# (spring.profiles.include 는 profile-specific 문서에서 금지 - InvalidConfigDataPropertyException)
jpa:
properties:
hibernate:
+12
View File
@@ -41,6 +41,14 @@ spring:
import:
- classpath:menu.yml
- classpath:roles.yml
profiles:
# dev/local_rinjaemac 단독 기동 시에도 playwright 전용 빈(test-cleanup 내부 API 등)이 뜨도록
# group 으로 자동 합류. (주의: spring.profiles.include 는 profile-specific 문서에서 금지 -
# application-{profile}.yml 의 on-profile 게이트 안에 두면 InvalidConfigDataPropertyException.
# 반드시 이 base 문서에 정의)
group:
dev: playwright
local_rinjaemac: playwright
data:
web:
pageable:
@@ -85,6 +93,10 @@ app:
# prod 는 PortalConfigWebDispatcherServlet 에서 항상 ON 으로 고정되어 이 값을 무시함.
resource-caching:
enabled: false
# 정적자원 서빙 루트. 기본은 classpath(빌드 산출물).
# local_rinjaemac 프로파일은 file: 로 소스 트리를 직접 바라보도록 오버라이드한다.
web-resources:
static-base: 'classpath:/static/'
security:
basic:
+344 -82
View File
@@ -23,6 +23,13 @@ body {
color: #1A1A2E;
background-color: #FFFFFF;
overflow-x: hidden;
min-height: 100vh;
display: flex;
flex-direction: column;
}
body > * {
flex-shrink: 0;
width: 100%;
}
ul, ol {
@@ -824,16 +831,20 @@ hr {
.env-badge {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
position: fixed;
top: 0;
left: 0;
padding: 1px 6px;
border-radius: 0 0 4px 0;
background: var(--accent-orange);
color: var(--white);
font-size: 11px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.6;
line-height: 1.5;
z-index: 1100;
pointer-events: none;
}
@media (max-width: 1024px) {
.env-badge {
@@ -897,7 +908,7 @@ hr {
}
.logo img {
height: 32px;
width: 114px;
width: auto;
display: block;
}
@@ -1866,6 +1877,7 @@ hr {
background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139);
padding: 60px 0px;
margin-top: auto;
}
.global-footer .container {
max-width: 1200px;
@@ -21839,6 +21851,35 @@ input[type=checkbox]:checked + .custom-checkbox {
color: #334155;
word-break: break-word;
}
.recent-apps .recent-apps-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.recent-apps .recent-apps-delete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
background: #ffffff;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #dc2626;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.recent-apps .recent-apps-delete:hover {
background: #fef2f2;
border-color: #f87171;
}
.recent-apps .recent-apps-delete:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
.page-title {
font-size: 40px;
@@ -23345,81 +23386,302 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.service-intro {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
color: #000;
display: flex;
flex-direction: column;
gap: 20px;
}
.service-intro__title {
margin: 0;
font-size: 25px;
line-height: 1.4;
letter-spacing: -0.01em;
}
.service-intro__lead {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.5;
letter-spacing: -0.01em;
}
.service-intro__desc {
margin: 0;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__spacer {
height: 10px;
}
.service-intro__section-title {
margin: 0;
font-size: 20px;
line-height: 1.5;
}
.service-intro__section-body {
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__section-body p {
margin: 0;
}
.service-intro__section-body p + p {
margin-top: 8px;
}
.service-intro__list {
margin: 0;
padding-left: 22px;
list-style: disc;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
.service-intro__list li + li {
margin-top: 4px;
color: #1e2939;
}
@media (max-width: 768px) {
.service-intro {
padding: 16px;
gap: 16px;
.intro-callout {
background: #0B2A5B;
border-radius: 16px;
padding: 34px 40px;
display: flex;
gap: 30px;
align-items: center;
box-shadow: 0 14px 34px rgba(11, 42, 91, 0.18);
}
.intro-callout__icon {
width: 56px;
height: 56px;
flex: none;
border-radius: 12px;
background: rgba(255, 255, 255, 0.14);
display: grid;
place-items: center;
}
.intro-callout ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 11px;
}
.intro-callout li {
position: relative;
padding-left: 15px;
color: #C2D4EA;
font-size: 15.5px;
line-height: 1.6;
}
.intro-callout li::before {
content: "";
position: absolute;
left: 0;
top: 10px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #00acdd;
}
.intro-callout li strong {
color: #fff;
font-weight: 700;
}
.intro-section__eyebrow {
display: block;
font-size: 13px;
font-weight: 800;
letter-spacing: 2.4px;
color: #0049b4;
margin-bottom: 9px;
}
.intro-section__title {
font-size: 30px;
font-weight: 900;
letter-spacing: -1px;
color: #0B2A5B;
margin: 0;
}
.intro-section__lead {
margin-top: 14px;
font-size: 16px;
color: #4a5565;
line-height: 1.85;
max-width: 830px;
}
.intro-section + .intro-section {
margin-top: 76px;
}
.intro-who {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 28px;
}
.intro-who__item {
border: 1px solid #f3f4f6;
border-radius: 14px;
padding: 22px 20px;
text-align: center;
background: #fff;
}
.intro-who__item p {
margin-top: 6px;
font-size: 13.6px;
color: #4a5565;
line-height: 1.65;
}
.intro-who__title {
margin-top: 12px;
font-size: 15.5px;
font-weight: 800;
color: #0B2A5B;
}
.intro-grid3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 28px;
}
.intro-card {
border: 1px solid #f3f4f6;
border-radius: 16px;
padding: 26px;
background: #fff;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.intro-card__icon {
width: 44px;
height: 44px;
border-radius: 11px;
background: #EFF6FD;
display: grid;
place-items: center;
margin-bottom: 16px;
}
.intro-card h3 {
font-size: 18px;
font-weight: 800;
color: #0B2A5B;
letter-spacing: -0.5px;
margin: 0;
}
.intro-card p {
margin-top: 9px;
font-size: 14.6px;
color: #4a5565;
line-height: 1.75;
}
.intro-card code {
font-family: "Fira Code", monospace;
font-size: 13px;
background: #EFF4FA;
color: #0049b4;
padding: 2px 6px;
border-radius: 6px;
}
.intro-card__tag {
display: inline-block;
margin-top: 14px;
font-size: 12.5px;
font-weight: 700;
color: #0049b4;
background: #EEF6FD;
border-radius: 6px;
padding: 5px 10px;
}
.intro-diagram {
margin-top: 28px;
border: 1px solid #f3f4f6;
border-radius: 16px;
padding: 30px 24px;
background: #FAFCFF;
overflow-x: auto;
}
.intro-diagram svg {
display: block;
width: 100%;
min-width: 700px;
height: auto;
}
.intro-steps {
margin-top: 34px;
position: relative;
padding-left: 38px;
}
.intro-steps::before {
content: "";
position: absolute;
left: 5px;
top: 14px;
bottom: 14px;
width: 2px;
background: #D8E5F3;
}
.intro-step {
display: flex;
gap: 22px;
margin-bottom: 18px;
position: relative;
}
.intro-step::before {
content: "";
position: absolute;
left: -38px;
top: 34px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #0049b4;
border: 3px solid #fff;
box-shadow: 0 0 0 3px #D9E7F7;
}
.intro-step__icon {
width: 88px;
height: 82px;
flex: none;
border: 1px solid #f3f4f6;
border-radius: 14px;
display: grid;
place-items: center;
background: #fff;
}
.intro-step__body {
flex: 1;
border: 1px solid #f3f4f6;
border-radius: 14px;
padding: 19px 26px;
background: #fff;
}
.intro-step__body p {
margin-top: 5px;
font-size: 14.4px;
color: #4a5565;
}
.intro-step__title {
font-size: 17px;
font-weight: 800;
color: #0B2A5B;
}
.intro-step__title em {
font-style: normal;
color: #0049b4;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.6px;
margin-right: 10px;
}
.intro-cta {
margin-top: 64px;
border-radius: 18px;
padding: 38px 44px;
background: linear-gradient(100deg, #EAF4FD, #DFEDFB);
display: flex;
align-items: center;
gap: 28px;
border: 1px solid #D5E6F7;
}
.intro-cta__body h3 {
margin: 0;
font-size: 22px;
color: #0B2A5B;
}
.intro-cta__body p {
margin-top: 6px;
font-size: 15px;
color: #4a5565;
}
.intro-cta .btn-action-primary {
margin-left: auto;
flex: none;
font-size: 15.5px;
padding: 15px 30px;
}
@media (max-width: 1024px) {
.intro-who,
.intro-grid3 {
grid-template-columns: repeat(2, 1fr);
}
.service-intro__title {
font-size: 22px;
.intro-cta {
flex-direction: column;
align-items: flex-start;
}
.service-intro__lead {
font-size: 17px;
.intro-cta .btn-action-primary {
margin-left: 0;
}
.service-intro__section-title {
font-size: 17px;
}
@media (max-width: 640px) {
.intro-who,
.intro-grid3 {
grid-template-columns: 1fr;
}
.service-intro__desc, .service-intro__section-body, .service-intro__list {
font-size: 14px;
.intro-callout {
flex-direction: column;
align-items: flex-start;
}
.intro-section__title {
font-size: 24px;
}
.intro-step {
flex-direction: column;
}
}
.service-main {
@@ -26148,7 +26410,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.step1-wrap .s1-form-card .webhook-info-group .secret-action-row {
display: flex;
align-items: center;
align-items: flex-start;
gap: 10px;
width: 100%;
}
@@ -26159,24 +26421,24 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
flex: 1;
max-width: 250px;
height: 48px;
min-width: 0;
min-height: 48px;
background-color: #efefef;
border-radius: 10px;
padding: 0 20px;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-size: 13px;
line-height: 1.4;
color: #4e5968;
font-weight: 500;
box-sizing: border-box;
letter-spacing: 1px;
word-break: break-all;
white-space: normal;
border: 1px solid #DFDFDF;
}
@media (max-width: 576px) {
.step1-wrap .s1-form-card .webhook-info-group .secret-box {
max-width: 100%;
width: 100%;
flex: none;
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+136 -49
View File
@@ -8,11 +8,13 @@
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
*
* design(figma s2) 인라인 스크립트 대비 패치 3건:
* design(figma s2) 인라인 스크립트 대비 패치 4건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
* 4) 클라이언트 페이징 — 카드는 현재 페이지분만 DOM 렌더, 검색/전체선택/모달은 필터된 전체 목록 기준으로 동작
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('apiSelectorForm');
@@ -20,16 +22,21 @@ document.addEventListener('DOMContentLoaded', function() {
return; // 모듈 미사용 페이지
}
const PAGE_SIZE = 12;
// DOM Elements
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
const paginationEl = document.getElementById('apiPagination');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체';
let allApis = [];
let allApis = []; // 현재 카테고리 조회 결과 전체
let filteredApis = []; // allApis 에 검색어까지 적용한 결과(페이징 대상)
let currentPage = 1;
let selectedApis = new Set();
// Restore selected APIs from session (fragment 인라인 주입)
@@ -44,8 +51,7 @@ document.addEventListener('DOMContentLoaded', function() {
function loadApis(groupId) {
loadingState.style.display = 'block';
emptyState.style.display = 'none';
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
clearCards();
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
let url = baseUrl;
@@ -56,16 +62,7 @@ document.addEventListener('DOMContentLoaded', function() {
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
applySearch();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
@@ -73,10 +70,49 @@ document.addEventListener('DOMContentLoaded', function() {
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
renderPagination(0);
});
}
// Render API cards
// 렌더된 카드만 제거(로딩/빈 상태 엘리먼트는 유지)
function clearCards() {
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
}
// 검색어 기준으로 allApis → filteredApis 재계산 후 1페이지부터 렌더
function applySearch() {
const term = searchInput ? searchInput.value.toLowerCase().trim() : '';
filteredApis = !term ? allApis : allApis.filter(function(api) {
const name = (api.apiName || '').toLowerCase();
const desc = (api.apiSimpleDescription || '').toLowerCase();
return name.includes(term) || desc.includes(term);
});
goToPage(1);
}
// 지정 페이지로 이동 — 해당 페이지분만 렌더(재조회 없음)
function goToPage(page) {
const totalPages = Math.max(1, Math.ceil(filteredApis.length / PAGE_SIZE));
currentPage = Math.min(Math.max(1, page), totalPages);
clearCards();
document.getElementById('apiResultCount').textContent = filteredApis.length;
if (filteredApis.length === 0) {
emptyState.style.display = 'block';
renderPagination(0);
updateSelectAllUI();
return;
}
emptyState.style.display = 'none';
const start = (currentPage - 1) * PAGE_SIZE;
renderApiCards(filteredApis.slice(start, start + PAGE_SIZE));
renderPagination(filteredApis.length);
updateSelectAllUI();
}
// Render API cards (현재 페이지분)
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
@@ -207,24 +243,25 @@ document.addEventListener('DOMContentLoaded', function() {
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
// Update select all checkbox state — 현재 페이지가 아닌 필터된 전체 목록 기준(페이징 무관)
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
if (!selectAllCheckbox) {
return;
}
if (visibleCards.length === 0) {
if (filteredApis.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
const checkedCount = filteredApis.filter(api => selectedApis.has(api.apiId)).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
} else if (checkedCount === filteredApis.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
@@ -233,7 +270,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
// Update modal selected APIs list — selectedApis Set 기준(패치 2), 이름은 allApis 우선 조회(패치 4)
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
@@ -244,8 +281,10 @@ document.addEventListener('DOMContentLoaded', function() {
}
selectedApis.forEach(function(apiId) {
const apiData = allApis.find(a => a.apiId === apiId);
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
const apiName = apiData ? apiData.apiName
: (card ? card.querySelector('.s2-api-card-title').textContent : apiId);
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
@@ -270,28 +309,69 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Search functionality
// Pagination 컨트롤 렌더 — fragment/pagination.html 과 동일 마크업/클래스 재사용(전역 _pagination.scss 적용)
function renderPagination(totalItems) {
if (!paginationEl) {
return;
}
paginationEl.innerHTML = '';
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
if (totalPages <= 1) {
return;
}
const ICON_FIRST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 19V5H2.30769V19H0ZM15 19L4.61538 12L15 5V19Z" fill="currentColor"/><path d="M24 19L15 12L24 5V19Z" fill="currentColor"/></svg><span class="blind">처음 페이지</span>';
const ICON_PREV = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 5L16 19L5 12L16 5Z" fill="currentColor"/></svg><span class="blind">이전 페이지</span>';
const ICON_NEXT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 19V5L19 12L8 19Z" fill="currentColor"/></svg><span class="blind">다음 페이지</span>';
const ICON_LAST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24 5L24 19L21.6923 19L21.6923 5L24 5ZM9 5L19.3846 12L9 19L9 5Z" fill="currentColor"/><path d="M1.22392e-06 5L9 12L0 19L1.22392e-06 5Z" fill="currentColor"/></svg><span class="blind">마지막 페이지</span>';
function navLink(cls, iconHtml, targetPage, disabled) {
const a = document.createElement('a');
a.href = '#';
a.className = cls + (disabled ? ' disabled' : '');
a.innerHTML = iconHtml;
if (!disabled) {
a.addEventListener('click', function(e) {
e.preventDefault();
goToPage(targetPage);
});
}
return a;
}
function numLink(p) {
const a = document.createElement('a');
a.href = '#';
const isCurrent = p === currentPage;
a.className = 'page-num' + (isCurrent ? ' page-current' : '');
a.textContent = String(p);
if (!isCurrent) {
a.addEventListener('click', function(e) {
e.preventDefault();
goToPage(p);
});
}
return a;
}
paginationEl.appendChild(navLink('page-first', ICON_FIRST, 1, currentPage === 1));
paginationEl.appendChild(navLink('page-prev', ICON_PREV, currentPage - 1, currentPage === 1));
const windowStart = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
const windowEnd = Math.min(totalPages, windowStart + 4);
for (let p = Math.max(1, windowStart); p <= windowEnd; p++) {
paginationEl.appendChild(numLink(p));
}
paginationEl.appendChild(navLink('page-next', ICON_NEXT, currentPage + 1, currentPage === totalPages));
paginationEl.appendChild(navLink('page-last', ICON_LAST, totalPages, currentPage === totalPages));
}
// Search functionality — 클라이언트 필터(재조회 없음), 필터 변경 시 1페이지로 리셋
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
updateSelectAllCheckboxState();
applySearch();
});
}
@@ -307,11 +387,10 @@ document.addEventListener('DOMContentLoaded', function() {
currentFilter = groupId;
currentServiceName = this.textContent.trim();
loadApis(groupId);
if (searchInput) {
searchInput.value = '';
}
loadApis(groupId);
});
});
@@ -398,18 +477,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Select All checkbox event
// Select All checkbox event — 현재 페이지가 아닌 필터된 전체 목록 대상(페이징 무관)
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
filteredApis.forEach(function(api) {
if (isChecked) {
selectedApis.add(api.apiId);
} else {
selectedApis.delete(api.apiId);
}
});
// 현재 페이지에 실제 렌더된 카드만 체크 상태 동기화
document.querySelectorAll('.s2-api-card').forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
card.classList.toggle('selected', isChecked);
}
});
@@ -30,6 +30,19 @@ body {
color: $text-dark;
background-color: $white;
overflow-x: hidden;
// 콘텐츠가 짧은 페이지에서도 footer 가 화면 하단에 붙도록(sticky footer).
// footer 는 .global-footer 의 margin-top:auto 로 밀려난다.
min-height: 100vh;
display: flex;
flex-direction: column;
// flex 컨테이너가 되면 자식이 축소(shrink)되거나 내부 콘텐츠 min-content 폭까지 늘어난다.
// block 레이아웃과 동일한 폭 계산이 되도록 고정한다(모바일 가로 스크롤 방지).
> * {
flex-shrink: 0;
width: 100%;
}
}
// Lists
@@ -163,6 +163,8 @@
background-color: rgb(15, 23, 42);
color: rgb(100, 116, 139);
padding: 60px 0px;
// body(flex column) 기준으로 남은 공간을 위쪽 여백으로 흡수 → 짧은 페이지에서 화면 하단 고정
margin-top: auto;
.container {
max-width: 1200px;
@@ -137,7 +137,8 @@
align-items: flex-end;
flex-wrap: nowrap;
// width 가 고정(114px)이라 flex 축소가 걸리면 가로만 눌려 비율이 깨진다
// 로고 파일(PTL_PROPERTY brand.logo.header.path)마다 원본 비율이 달라 width는 auto로 두고
// height만 고정한다. flex 축소가 걸리면 그 auto width가 눌릴 수 있어 shrink는 막아둔다.
img { flex-shrink: 0; }
.mobile-logo-link {
@@ -157,20 +158,26 @@
}
// 활성 Spring 프로파일 표시 (prod 제외)
// 데스크톱: 로고 옆 인라인 뱃지 / 모바일·태블릿: 최상단 floating 바(fixed, 레이아웃 미영향)
// 데스크톱: 화면 최좌측·최상단 floating 뱃지 / 모바일·태블릿: 최상단 floating 바
// 둘 다 fixed 라 헤더 레이아웃(로고·메뉴 정렬)에는 영향을 주지 않는다.
.env-badge {
display: inline-flex;
align-items: center;
margin-left: 10px;
padding: 2px 8px;
border-radius: 4px;
position: fixed;
top: 0;
left: 0;
padding: 1px 6px;
// 좌상단 모서리에 붙으므로 우/하단만 둥글게
border-radius: 0 0 4px 0;
background: var(--accent-orange);
color: var(--white);
font-size: 11px;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
text-transform: uppercase;
line-height: 1.6;
line-height: 1.5;
z-index: 1100;
pointer-events: none;
@media (max-width: 1024px) {
display: none;
@@ -239,7 +246,7 @@
img {
height: 32px;
width: 114px;
width: auto;
display: block;
}
}
@@ -102,6 +102,40 @@
color: #334155;
word-break: break-word;
}
// 본인 글 삭제 버튼(아코디언 펼친 상태에서만 노출)
.recent-apps-actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.recent-apps-delete {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 16px;
background: #ffffff;
border: 1px solid #fca5a5;
border-radius: 6px;
color: #dc2626;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
&:hover {
background: #fef2f2;
border-color: #f87171;
}
&:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.15);
}
}
}
.page-title {
@@ -25,101 +25,348 @@ $service-card-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgb
// DJBank 개발자포탈 소개 (Figma 352:15 기반)
// =============================================================================
$intro-navy: #0B2A5B;
.service-intro {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #fff;
color: #000;
color: $service-text-dark;
}
// -----------------------------------------------------------------------------
// Intro page content sections (callout / eyebrow+title / card grids / steps)
// -----------------------------------------------------------------------------
.intro-callout {
background: $intro-navy;
border-radius: 16px;
padding: 34px 40px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 30px;
align-items: center;
box-shadow: 0 14px 34px rgba(11, 42, 91, .18);
&__title {
&__icon {
width: 56px;
height: 56px;
flex: none;
border-radius: 12px;
background: rgba(255, 255, 255, .14);
display: grid;
place-items: center;
}
ul {
list-style: none;
margin: 0;
font-size: 25px;
line-height: 1.4;
letter-spacing: -0.01em;
padding: 0;
display: grid;
gap: 11px;
}
&__lead {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.5;
letter-spacing: -0.01em;
}
li {
position: relative;
padding-left: 15px;
color: #C2D4EA;
font-size: 15.5px;
line-height: 1.6;
&__desc {
margin: 0;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
}
&__spacer {
height: 10px;
}
&__section-title {
margin: 0;
font-size: 20px;
line-height: 1.5;
}
&__section-body {
font-size: 15px;
font-weight: 500;
line-height: 1.7;
p {
margin: 0;
&+p {
margin-top: 8px;
}
&::before {
content: "";
position: absolute;
left: 0;
top: 10px;
width: 5px;
height: 5px;
border-radius: 50%;
background: $service-icon-cyan;
}
}
&__list {
margin: 0;
padding-left: 22px;
list-style: disc;
font-size: 15px;
font-weight: 500;
line-height: 1.7;
li+li {
margin-top: 4px;
strong {
color: #fff;
font-weight: 700;
}
}
}
@media (max-width: 768px) {
.service-intro {
padding: 16px;
gap: 16px;
.intro-section {
&__eyebrow {
display: block;
font-size: 13px;
font-weight: 800;
letter-spacing: 2.4px;
color: $service-primary-blue;
margin-bottom: 9px;
}
&__title {
font-size: 22px;
&__title {
font-size: 30px;
font-weight: 900;
letter-spacing: -1px;
color: $intro-navy;
margin: 0;
}
&__lead {
margin-top: 14px;
font-size: 16px;
color: $service-text-gray;
line-height: 1.85;
max-width: 830px;
}
&+& {
margin-top: 76px;
}
}
.intro-who {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
margin-top: 28px;
&__item {
border: 1px solid $service-card-border;
border-radius: 14px;
padding: 22px 20px;
text-align: center;
background: #fff;
p {
margin-top: 6px;
font-size: 13.6px;
color: $service-text-gray;
line-height: 1.65;
}
}
&__lead {
font-size: 17px;
&__title {
margin-top: 12px;
font-size: 15.5px;
font-weight: 800;
color: $intro-navy;
}
}
.intro-grid3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 28px;
}
.intro-card {
border: 1px solid $service-card-border;
border-radius: 16px;
padding: 26px;
background: #fff;
box-shadow: $service-card-shadow;
&__icon {
width: 44px;
height: 44px;
border-radius: 11px;
background: #EFF6FD;
display: grid;
place-items: center;
margin-bottom: 16px;
}
h3 {
font-size: 18px;
font-weight: 800;
color: $intro-navy;
letter-spacing: -.5px;
margin: 0;
}
p {
margin-top: 9px;
font-size: 14.6px;
color: $service-text-gray;
line-height: 1.75;
}
code {
font-family: 'Fira Code', monospace;
font-size: 13px;
background: #EFF4FA;
color: $service-primary-blue;
padding: 2px 6px;
border-radius: 6px;
}
&__tag {
display: inline-block;
margin-top: 14px;
font-size: 12.5px;
font-weight: 700;
color: $service-primary-blue;
background: #EEF6FD;
border-radius: 6px;
padding: 5px 10px;
}
}
.intro-diagram {
margin-top: 28px;
border: 1px solid $service-card-border;
border-radius: 16px;
padding: 30px 24px;
background: #FAFCFF;
overflow-x: auto;
svg {
display: block;
width: 100%;
min-width: 700px;
height: auto;
}
}
.intro-steps {
margin-top: 34px;
position: relative;
padding-left: 38px;
&::before {
content: "";
position: absolute;
left: 5px;
top: 14px;
bottom: 14px;
width: 2px;
background: #D8E5F3;
}
}
.intro-step {
display: flex;
gap: 22px;
margin-bottom: 18px;
position: relative;
&::before {
content: "";
position: absolute;
left: -38px;
top: 34px;
width: 12px;
height: 12px;
border-radius: 50%;
background: $service-primary-blue;
border: 3px solid #fff;
box-shadow: 0 0 0 3px #D9E7F7;
}
&__icon {
width: 88px;
height: 82px;
flex: none;
border: 1px solid $service-card-border;
border-radius: 14px;
display: grid;
place-items: center;
background: #fff;
}
&__body {
flex: 1;
border: 1px solid $service-card-border;
border-radius: 14px;
padding: 19px 26px;
background: #fff;
p {
margin-top: 5px;
font-size: 14.4px;
color: $service-text-gray;
}
}
&__section-title {
font-size: 17px;
}
&__title {
font-size: 17px;
font-weight: 800;
color: $intro-navy;
&__desc,
&__section-body,
&__list {
em {
font-style: normal;
color: $service-primary-blue;
font-size: 14px;
font-weight: 800;
letter-spacing: .6px;
margin-right: 10px;
}
}
}
.intro-cta {
margin-top: 64px;
border-radius: 18px;
padding: 38px 44px;
background: linear-gradient(100deg, #EAF4FD, #DFEDFB);
display: flex;
align-items: center;
gap: 28px;
border: 1px solid #D5E6F7;
&__body {
h3 {
margin: 0;
font-size: 22px;
color: $intro-navy;
}
p {
margin-top: 6px;
font-size: 15px;
color: $service-text-gray;
}
}
.btn-action-primary {
margin-left: auto;
flex: none;
font-size: 15.5px;
padding: 15px 30px;
}
}
@media (max-width: 1024px) {
.intro-who,
.intro-grid3 {
grid-template-columns: repeat(2, 1fr);
}
.intro-cta {
flex-direction: column;
align-items: flex-start;
.btn-action-primary {
margin-left: 0;
}
}
}
@media (max-width: 640px) {
.intro-who,
.intro-grid3 {
grid-template-columns: 1fr;
}
.intro-callout {
flex-direction: column;
align-items: flex-start;
}
.intro-section__title {
font-size: 24px;
}
.intro-step {
flex-direction: column;
}
}
// =============================================================================
// Service Common Sidebar & Main Layout
@@ -756,7 +756,7 @@ $wh-bg-soft: #f9f9f9;
.secret-action-row {
display: flex;
align-items: center;
align-items: flex-start;
gap: 10px;
width: 100%;
@@ -767,23 +767,23 @@ $wh-bg-soft: #f9f9f9;
.secret-box {
flex: 1;
max-width: 250px;
height: 48px;
min-width: 0;
min-height: 48px;
background-color: #efefef;
border-radius: 10px;
padding: 0 20px;
padding: 12px 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-size: 13px;
line-height: 1.4;
color: #4e5968;
font-weight: 500;
box-sizing: border-box;
letter-spacing: 1px;
word-break: break-all;
white-space: normal;
border: 1px solid #DFDFDF;
@media (max-width: 576px) {
max-width: 100%;
width: 100%;
flex: none;
}
@@ -16,7 +16,7 @@
<span class="service-hero__badge-text">OPEN API 목록</span>
</div>
<h1 class="service-hero__title">OPEN API</h1>
<p class="service-hero__desc">비즈니스 확장을 위한 DJBank의 핵심 API 인프라를 제공합니다.<br>원하는 API를 선택하여 상세 가이드를 확인하고, 테스트 키를
<p class="service-hero__desc">비즈니스 확장을 위한 [[${brandName}]]의 핵심 API 인프라를 제공합니다.<br>원하는 API를 선택하여 상세 가이드를 확인하고, 테스트 키를
발급받아 지금 바로 개발을 시작해 보세요.</p>
</div>
</div>
@@ -15,7 +15,7 @@
<div class="inner i_cs h_inner8 h_inner10">
<div class="swagger_title m-only">
<p class="title">API 테스트 베드</p>
<p class="text">DJBank API Portal은 Swagger를 이용해 API를 테스트 할 수 있습니다.</p>
<p class="text">[[${brandName}]] API Portal은 Swagger를 이용해 API를 테스트 할 수 있습니다.</p>
</div>
<div class="custom_select select_w h_inp" id="apiList">
@@ -96,7 +96,7 @@
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect>
<path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
</svg>
제주은행(DJBank) 보안 인증
제주은행([[${brandName}]]) 보안 인증
</span>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">Q&A 게시판</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
드리겠습니다.</p>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">Q&A 게시판</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
드리겠습니다.</p>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">Q&A 게시판</span>
</div>
<h1 class="service-hero__title">Q&A</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 의문점이나 불편 사항을 보내주세요<br>접수해주신 문의 사항은 담당자 확인 후 빠르게 안내해
드리겠습니다.</p>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">고객지원</span>
</div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
주시기 바랍니다.</p>
</div>
</div>
@@ -24,7 +24,7 @@
<span class="service-hero__badge-text">고객지원</span>
</div>
<h1 class="service-hero__title">공지사항</h1>
<p class="service-hero__desc">DJBank 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
<p class="service-hero__desc">[[${brandName}]] 오픈 API 포털의 주요 안내 및 업데이트 소식을 전해드립니다.<br>원활한 서비스 연동을 위해 변경 사항을 주기적으로 확인해
주시기 바랍니다</p>
</div>
</div>
@@ -25,7 +25,7 @@
<span class="service-hero__badge-text">피드백/개선요청</span>
</div>
<h1 class="service-hero__title">피드백/개선요청</h1>
<p class="service-hero__desc">DJBank 오픈 API 이용 중 발생한 피드백이나 개선요청을 보내주세요<br>작성해주신 내용은 담당자 확인 후 적극 반영하겠습니다.</p>
<p class="service-hero__desc">[[${brandName}]] 오픈 API 이용 중 발생한 피드백이나 개선요청을 보내주세요<br>작성해주신 내용은 담당자 확인 후 적극 반영하겠습니다.</p>
</div>
</div>
</section>
@@ -57,6 +57,21 @@
</button>
<div class="recent-apps-body">
<p class="recent-apps-detail" th:text="${item.bizDetail}">내용</p>
<div class="recent-apps-actions">
<form class="recent-apps-delete-form" method="post"
th:action="@{/partnership/{id}/delete(id=${item.id})}">
<button type="button" class="recent-apps-delete" th:attr="data-subject=${item.bizSubject}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" aria-hidden="true">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path>
<path d="M10 11v6"></path>
<path d="M14 11v6"></path>
</svg>
삭제
</button>
</form>
</div>
</div>
</li>
</ul>
@@ -74,7 +89,7 @@
<div class="board-header">
<h2 class="board-title">피드백 / 개선 요청
</h2>
<p class="board-desc">DJ Bank은 온라인 비즈니스 혁신을 위한 피드백/개선요청을
<p class="board-desc">[[${brandName}]][[${brandNameJosaEun}]] 온라인 비즈니스 혁신을 위한 피드백/개선요청을
환영합니다.</p>
</div>
@@ -265,6 +280,17 @@
item.siblings('.recent-apps-item').removeClass('active').find('.recent-apps-body').slideUp(200);
});
// 최근 글 삭제 — 확인 팝업 후 항목별 form 전송(POST /partnership/{id}/delete)
$('.recent-apps-delete').on('click', function (e) {
e.preventDefault();
e.stopPropagation();
const form = $(this).closest('form.recent-apps-delete-form').get(0);
const subject = $(this).data('subject') || '';
customPopups.showConfirm('피드백/개선요청 [' + subject + '] 을(를) 삭제하시겠습니까?', function (ok) {
if (ok) form.submit();
});
});
// Focus on subject field
document.getElementById('bizSubject').focus();
});
@@ -19,7 +19,7 @@
</div>
<h2 class="login-title">로그인</h2>
</div>
<p class="login-message-sub">DJ Bank에 오신걸 환영합니다</p>
<p class="login-message-sub">[[${brandName}]]에 오신걸 환영합니다</p>
</div>
<!-- Alert Messages -->
@@ -39,6 +39,10 @@
<i class="fas fa-info-circle"></i>
<span>인증된 사용자만 접근 가능한 페이지입니다. 로그인 후 이용해 주세요.</span>
</div>
<div th:if="${param.pwFailExceeded}" class="login-alert alert-info">
<i class="fas fa-info-circle"></i>
<span>비밀번호 확인 5회 실패로 로그아웃되었습니다. 다시 로그인해 주세요.</span>
</div>
<!-- Login Form -->
<form id="loginForm" role="form" name="loginForm" th:action="@{/actionLogin.do}" method="post"
@@ -15,7 +15,7 @@
<div class="hero-text-content">
<div class="hero-text">
<p class="hero-subtitle">세상의 모든 서비스</p>
<h2 class="hero-title">DJBank API가<br>함께 합니다.</h2>
<h2 class="hero-title">[[${brandName}]] API가<br>함께 합니다.</h2>
</div>
<a th:href="@{/service/guide}" class="btn-hero-signup">회원 가입 안내 <i class="bi bi-chevron-right"></i></a>
</div>
@@ -45,7 +45,7 @@
<div class="hero-text-content">
<div class="hero-text">
<p class="hero-subtitle">문서는 상세하게, 연동은 확실하게</p>
<h2 class="hero-title">준비된 DJBank API로 <br>완벽한 서비스를 성공하세요.</h2>
<h2 class="hero-title">준비된 [[${brandName}]] API로 <br>완벽한 서비스를 성공하세요.</h2>
</div>
<a th:href="@{/service/oauth2-guide}" class="btn-hero-signup">개발 가이드 보기 <i
class="bi bi-chevron-right"></i></a>
@@ -234,7 +234,7 @@
<span th:if="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}"
th:text="${service.groupDesc}">서비스 설명</span>
<span th:unless="${service.groupDesc != null and !#strings.isEmpty(service.groupDesc)}">
DJBank API 서비스를 이용해보세요.
[[${brandName}]] API 서비스를 이용해보세요.
</span>
</p>
<div class="card-illustration">
@@ -286,13 +286,13 @@
<div class="info-content">
<h2 class="info-title">
<span class="title-sub">차별화된 API 서비스</span>
<span class="title-highlight">DJBank API Portal</span>
<span class="title-highlight">[[${brandName}]] API Portal</span>
</h2>
<p class="info-description">
DJBank API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
[[${brandName}]] API Portal은 기업이 혁신적인 금융 서비스를 쉽고 신속하게 개발하고 구현할 수 있도록 엄선된 API 명세와 직관적인 샌드박스 테스트 환경을 무상으로 지원합니다.
</p>
<div class="action-buttons">
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 DJBank API</a>
<a th:href="@{/service/intro}" class="action-btn btn-secondary">처음 만나는 [[${brandName}]] API</a>
<a th:href="@{/partnership}" class="action-btn btn-primary">피드백 / 개선요청 <i
class="bi bi-patch-question"></i></a>
</div>
@@ -323,7 +323,7 @@
<div class="support-header">
<h2 class="support-title">
<span class="title-regular">비즈니스의 시작,</span><br>
<span class="title-bold">DJBank 오픈 API가 함께 하겠습니다.</span>
<span class="title-bold">[[${brandName}]] 오픈 API가 함께 하겠습니다.</span>
</h2>
</div>
@@ -339,7 +339,7 @@
</div>
<div class="card-content">
<h3>공지사항</h3>
<p>DJBank API의 다양한 새로운 소식을 가장 먼저 전해드립니다.</p>
<p>[[${brandName}]] API의 다양한 새로운 소식을 가장 먼저 전해드립니다.</p>
</div>
<div class="card-arrow">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor"
@@ -473,7 +473,7 @@
<div class="stats-header">
<h2 class="stats-title">
<span class="title-top">우리 곁의 수많은 서비스들이</span><br>
<span class="title-highlight">DJBank 오픈 API</span>와 함께하고 있습니다.
<span class="title-highlight">[[${brandName}]] 오픈 API</span>와 함께하고 있습니다.
</h2>
</div>
@@ -548,7 +548,7 @@
<div class="cta-background"></div>
<div class="container">
<div class="cta-content">
<h2 class="cta-title">DJBank API를 지금 바로 만나보세요.</h2>
<h2 class="cta-title">[[${brandName}]] API를 지금 바로 만나보세요.</h2>
<a th:href="@{/signup}" class="btn-signup-cta">회원가입하기</a>
</div>
<img class="ctaImg right" th:src="@{/img/avatar1.svg}" ali="아바타1">
@@ -754,15 +754,15 @@
if (withdrawalBtn) {
withdrawalBtn.addEventListener('click', (e) => {
e.preventDefault();
customPopups.showWithdrawal();
customPopups.showAlert(
'법인 관리자는 회원 탈퇴를 할 수 없습니다.<br>' +
'관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.'
);
});
}
});
</script>
</th:block>
<section layout:fragment="pagePopups">
<th:block th:replace="~{fragment/popup/withdrawalPopup :: withdrawalPopup}"></th:block>
</section>
</body>
</html>
</html>
@@ -110,7 +110,7 @@
비밀번호 확인 <span class="required-badge">필수</span>
</label>
<div class="org-form-input-wrapper">
<input type="password" name="password2" id="password2" class="org-form-input"
<input type="password" name="confirmPassword" id="confirmPassword" class="org-form-input"
th:placeholder="#{portalUser.Register.passConfirm}">
<input type="hidden" name="isPasswordMatch" id="isPasswordMatch" />
<div id="password-match-validation" class="org-validation-message"></div>
@@ -411,9 +411,9 @@
});
// 비밀번호 확인 검증
$('#password2').on('blur', function () {
let password2 = $(this).val();
if (!password2) {
$('#confirmPassword').on('blur', function () {
let confirmPassword = $(this).val();
if (!confirmPassword) {
return;
}
@@ -422,7 +422,7 @@
type: 'POST',
data: {
password: $('#password').val(),
password2: password2,
confirmPassword: confirmPassword,
_csrf: $('input[name="_csrf"]').val()
},
success: function (response) {
@@ -136,7 +136,7 @@
// 시나리오별 필수 필드 정의
const requiredFieldsByScenario = {
new: {
user: ['loginId', 'userName', 'password', 'password2', 'mobileNumber', 'authNumber'],
user: ['loginId', 'userName', 'password', 'confirmPassword', 'mobileNumber', 'authNumber'],
org: ['compRegNo', 'corpRegNo', 'orgName', 'compRegFile', 'files']
},
retain: {
@@ -228,6 +228,9 @@
});
// Add confirmPassword manually based on the scenario
// (new 시나리오는 #confirmPassword 필드 자체가 있어 위 공통 user 필드 루프가 그대로 처리한다.
// 서버는 시나리오 무관하게 confirmPassword 단일 필드로 비밀번호 확인을 검증한다 —
// OrgRegisterFacadeImpl.registerNewOrgUser 참고.)
if (registrationScenario === 'retain') {
const passwordConfirmIndividual = document.getElementById('passwordConfirmIndividual');
if (passwordConfirmIndividual) {
@@ -17,7 +17,7 @@
</svg>
<h2 class="signup-title">회원가입</h2>
</div>
<p class="signup-message">DJBank API Portal 사용을 위해 회원 가입해 주세요.</p>
<p class="signup-message">[[${brandName}]] API Portal 사용을 위해 회원 가입해 주세요.</p>
</div>
<!-- Signup Cards -->
@@ -28,7 +28,7 @@
</p>
<p class="info-text" style="margin-top: 16px;">
<strong th:text="${orgName}"></strong>에서
DJBank API Portal 법인회원으로 초대하였습니다.
[[${brandName}]] API Portal 법인회원으로 초대하였습니다.
</p>
</div>
@@ -38,7 +38,7 @@
<span th:text="${userName}"></span>님, 법인회원 초대가 도착했습니다.
</strong>
<p>
<strong><span th:text="${orgName}"></span></strong>에서 DJBank API Portal 법인회원으로 초대하였습니다.<br>
<strong><span th:text="${orgName}"></span></strong>에서 [[${brandName}]] API Portal 법인회원으로 초대하였습니다.<br>
초대를 수락하시면 법인회원으로 전환됩니다.
</p>
</div>
@@ -18,7 +18,7 @@
<span class="service-hero__badge-text">회원가입 소개</span>
</div>
<h1 class="service-hero__title">회원 가입 안내</h1>
<p class="service-hero__desc">DJ Bank API 개발자 포털에 방문해 주셔서 감사합니다.<br>DJBank API 사용을 위해서는 다음과 같은
<p class="service-hero__desc">[[${brandName}]] API 개발자 포털에 방문해 주셔서 감사합니다.<br>[[${brandName}]] API 사용을 위해서는 다음과 같은
이용절차로 진행하여야 합니다.</p>
</div>
</div>
@@ -5,9 +5,235 @@
<body>
<th:block layout:fragment="contentFragment">
<section class="service-intro">
<h1 class="service-intro__title">DJBank API Portal 소개</h1>
</section>
<div class="service-intro">
<!-- Hero -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<svg width="230" height="167" viewBox="0 0 300 200" fill="none" aria-hidden="true">
<ellipse cx="150" cy="178" rx="98" ry="14" fill="#0B2A5B" opacity=".88"/>
<path d="M44 96h212" stroke="#0049B4" stroke-width="6" stroke-linecap="round"/>
<path d="M60 96v58h180V96" stroke="#0049B4" stroke-width="6" stroke-linejoin="round"/>
<path d="M40 96 150 38l110 58" stroke="#0B2A5B" stroke-width="7" stroke-linejoin="round" fill="#fff"/>
<rect x="84" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="118" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="168" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="202" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<circle cx="150" cy="74" r="13" fill="#F08A24"/>
<path d="M144 74h12M150 68v12" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<path d="M22 140h16m-8-8v16" stroke="#B9D3EC" stroke-width="5" stroke-linecap="round"/>
<path d="M262 140h16m-8-8v16" stroke="#B9D3EC" stroke-width="5" stroke-linecap="round"/>
</svg>
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">서비스 소개</span>
</div>
<h1 class="service-hero__title">[[${brandName}]]의 금융을<br>API로 연결합니다</h1>
<p class="service-hero__desc">
1969년 제주에서 시작해 신한금융그룹과 함께 성장해 온 [[${brandName}]][[${brandNameJosaGa}]]<br>
인증·조회·이체·기업여신 서비스를 표준 오픈 API로 개방합니다.
</p>
</div>
</div>
</section>
<div class="container service-main">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('intro')}"></th:block>
<section class="service-content">
<!-- Callout -->
<div class="intro-callout">
<div class="intro-callout__icon">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="m6 13.4 5 5 9.5-11" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<ul>
<li>[[${brandName}]] API 포탈은 <strong>핀테크·법인·솔루션 사업자</strong>가 [[${brandName}]] 금융 서비스를 연동하는 <strong>공식 파트너 채널</strong>입니다.</li>
<li>API 명세·샌드박스·이용 신청·호출 이력을 <strong>한 곳에서</strong> 제공하며, 모든 호출은 <strong>OAuth2 기반 인증</strong>으로 보호됩니다.</li>
<li>이용은 <strong>회원가입 → 심사·승인 → 앱 등록 → 테스트 → 운영 전환</strong> 순으로 진행됩니다.</li>
</ul>
</div>
<!-- ABOUT -->
<section class="intro-section" aria-labelledby="intro-about-title">
<span class="intro-section__eyebrow">ABOUT PORTAL</span>
<h2 class="intro-section__title" id="intro-about-title">[[${brandName}]] 오픈 API 포탈이란</h2>
<p class="intro-section__lead">
1969년 설립된 제주은행은 반세기 넘게 지역 경제의 중추 역할을 해왔고, 신한금융지주회사의 자회사 편입 이후
디지털 전환에 속도를 내고 있습니다. 은행의 비전인 <strong>&ldquo;제주를 더 가깝고, 더 편리하게 &mdash; 당신의 설렘을 담은 은행&rdquo;</strong>
창구를 넘어 고객이 이미 사용하는 서비스 안으로 금융을 옮기는 일에서 시작합니다.<br><br>
API 포탈은 그 실행 도구입니다. 계좌 조회와 이체 같은 기본 뱅킹부터 기업여신·수납·알림까지,
내부에서만 쓰이던 금융 기능을 표준 REST API와 웹훅으로 정리해 외부 파트너에게 개방합니다.
문서·샌드박스·키 관리·모니터링을 하나의 화면에서 제공하므로, 별도 협의 없이도 연동 설계를 먼저 시작할 수 있습니다.
</p>
</section>
<!-- WHO -->
<section class="intro-section" aria-labelledby="intro-who-title">
<span class="intro-section__eyebrow">FOR WHOM</span>
<h2 class="intro-section__title" id="intro-who-title">이런 분들이 이용합니다</h2>
<div class="intro-who">
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="3" stroke="#0049B4" stroke-width="1.8"/><path d="M8 12h8M8 15.5h5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round"/></svg>
<div class="intro-who__title">핀테크 기업</div>
<p>결제·자산관리 서비스에 은행 계좌 기능을 탑재</p>
</div>
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><path d="M4 20V8l8-4 8 4v12" stroke="#0049B4" stroke-width="1.8" stroke-linejoin="round"/><path d="M9.5 20v-5h5v5" stroke="#00ACDD" stroke-width="1.8" stroke-linejoin="round"/></svg>
<div class="intro-who__title">법인 · 기업 고객</div>
<p>자체 ERP·그룹웨어에서 자금 업무 자동화</p>
</div>
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><path d="M9 6 4 12l5 6m6-12 5 6-5 6" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div class="intro-who__title">ERP · 회계 솔루션사</div>
<p>SaaS 제품에 임베디드 뱅킹 기능 제공</p>
</div>
</div>
</section>
<!-- SERVICES -->
<section class="intro-section" aria-labelledby="intro-services-title">
<span class="intro-section__eyebrow">API SERVICES</span>
<h2 class="intro-section__title" id="intro-services-title">제공 서비스</h2>
<p class="intro-section__lead">6개 도메인으로 구성되며, 서비스별로 이용 신청과 심사가 개별 진행됩니다.</p>
<div class="intro-grid3">
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="4" y="9.5" width="14" height="9" rx="2.4" stroke="#0049B4" stroke-width="2"/><path d="M7.5 9.5V7a3.5 3.5 0 1 1 7 0v2.5" stroke="#0049B4" stroke-width="2"/></svg></div>
<h3>인증 · 토큰</h3>
<p>OAuth2 client_credentials로 access_token을 발급하고, 모든 호출에 Bearer 토큰을 사용합니다.</p>
<span class="intro-card__tag">OAuth2</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="2.5" y="5" width="17" height="12" rx="2.6" stroke="#0049B4" stroke-width="2"/><path d="M2.5 9.5h17" stroke="#0049B4" stroke-width="2"/></svg></div>
<h3>계좌 · 조회</h3>
<p>실명확인, 계좌 개설, 잔액·거래내역 조회 등 기본 뱅킹 조회 기능을 제공합니다.</p>
<span class="intro-card__tag">Account</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M4 7h11l-3-3m6 11H7l3 3" stroke="#0049B4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h3>이체 · 자금</h3>
<p>단건·대량이체, 급여이체, 예약이체와 이체 결과 조회를 지원합니다.</p>
<span class="intro-card__tag">Transfer</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M3 18V9m5 9V4m5 14v-6m5 6V7" stroke="#0049B4" stroke-width="2.2" stroke-linecap="round"/></svg></div>
<h3>기업여신</h3>
<p>사전 한도 조회, 대출 신청·실행·상환, 매출채권 기반 여신 연계를 처리합니다.</p>
<span class="intro-card__tag">Loan</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><circle cx="11" cy="11" r="7.5" stroke="#0049B4" stroke-width="2"/><path d="M11 6.5v5l3 2" stroke="#0049B4" stroke-width="2" stroke-linecap="round"/></svg></div>
<h3>수납 · 외환</h3>
<p>가상계좌 발급·입금 통지, 공과금 수납, 환율 조회 등 부가 금융 서비스입니다.</p>
<span class="intro-card__tag">Billing / FX</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M11 3a6 6 0 0 1 6 6v4l2 3H4l2-3V9a6 6 0 0 1 5-6Z" stroke="#0049B4" stroke-width="2" stroke-linejoin="round"/><path d="M9 18.5a2.2 2.2 0 0 0 4 0" stroke="#0049B4" stroke-width="2" stroke-linecap="round"/></svg></div>
<h3>웹훅 · 알림</h3>
<p>입출금, 심사 결과, 상태 변경 이벤트를 등록된 URL로 실시간 전송합니다.</p>
<span class="intro-card__tag">Webhook</span>
</div>
</div>
</section>
<!-- ARCHITECTURE -->
<section class="intro-section" aria-labelledby="intro-arch-title">
<span class="intro-section__eyebrow">ARCHITECTURE</span>
<h2 class="intro-section__title" id="intro-arch-title">연동 구조</h2>
<div class="intro-diagram">
<svg width="100%" viewBox="0 0 950 250" fill="none">
<rect x="8" y="48" width="200" height="152" rx="14" fill="#EDF9FE" stroke="#C6DEF5"/>
<text x="108" y="38" text-anchor="middle" font-size="13" font-weight="700" fill="#0049B4">PARTNER</text>
<text x="108" y="106" text-anchor="middle" font-size="16" font-weight="800" fill="#0B2A5B">파트너 시스템</text>
<text x="108" y="134" text-anchor="middle" font-size="12.5" fill="#55688A">핀테크 앱 · ERP · 회계 SaaS</text>
<text x="108" y="154" text-anchor="middle" font-size="12.5" fill="#55688A">법인 자체 시스템</text>
<path d="M214 124h96" stroke="#0049B4" stroke-width="2.4"/><path d="m306 118 10 6-10 6" fill="#0049B4"/>
<text x="262" y="112" text-anchor="middle" font-size="11.5" font-weight="700" fill="#0049B4">HTTPS / OAuth2</text>
<rect x="322" y="26" width="286" height="196" rx="14" fill="#0B2A5B"/>
<text x="465" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="#4E9BE0">JEJU BANK API PORTAL</text>
<rect x="346" y="74" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="405" y="100" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">인증 서버</text>
<rect x="470" y="74" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="529" y="100" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">API Gateway</text>
<rect x="346" y="126" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="405" y="152" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">유량 · IP 제어</text>
<rect x="470" y="126" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="529" y="152" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">로그 · 모니터링</text>
<text x="465" y="198" text-anchor="middle" font-size="11.5" fill="#9EB6D6">샌드박스 · 키 관리 · 호출 이력 · 통계</text>
<path d="M614 124h96" stroke="#0049B4" stroke-width="2.4"/><path d="m706 118 10 6-10 6" fill="#0049B4"/>
<text x="662" y="112" text-anchor="middle" font-size="11.5" font-weight="700" fill="#0049B4">내부 전문</text>
<rect x="722" y="48" width="212" height="152" rx="14" fill="#EDF9FE" stroke="#C6DEF5"/>
<text x="828" y="38" text-anchor="middle" font-size="13" font-weight="700" fill="#0049B4">CORE BANKING</text>
<text x="828" y="102" text-anchor="middle" font-size="16" font-weight="800" fill="#0B2A5B">[[${brandName}]] 계정계</text>
<text x="828" y="130" text-anchor="middle" font-size="12.5" fill="#55688A">수신 · 여신 · 외환 원장</text>
<text x="828" y="150" text-anchor="middle" font-size="12.5" fill="#55688A">심사 · 컴플라이언스</text>
</svg>
</div>
</section>
<!-- PROCESS -->
<section class="intro-section" aria-labelledby="intro-process-title">
<span class="intro-section__eyebrow">PROCESS</span>
<h2 class="intro-section__title" id="intro-process-title">이용 절차</h2>
<div class="intro-steps">
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><circle cx="10" cy="8" r="3.4" stroke="#0049B4" stroke-width="1.8"/><path d="M3 20c.6-3.6 3.3-5.4 7-5.4" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round"/><path d="M17 13v7m-3.5-3.5h7" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 01</em>회원가입</div><p>법인 회원으로 온라인 가입을 신청합니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="3" stroke="#0049B4" stroke-width="1.8"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 02</em>승인</div><p>운영 담당자가 신청 계정 정보를 확인한 후 승인합니다. 법인 관리자는 승인 이후 실제 사용할 직원(개발자)을 추가 등록합니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M9.5 4.5 3.5 12l6 7.5" stroke="#F08A24" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="m14.5 4.5 6 7.5-6 7.5" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 03</em>API / APP 사용 신청</div><p>[내 앱]에서 애플리케이션을 등록하고 필요한 API를 선택해 신청하면 ClientID / Secret이 발급됩니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M4 17.5 9 12l3.5 3.5L20 8" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 8h5v5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 04</em>샌드박스 개발 · 테스트</div><p>테스트 키로 토큰 발급과 API 호출, 웹훅 수신을 검증합니다. 테스트 데이터는 실제 원장에 반영되지 않습니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M12 3 4 6.5v6c0 4.5 3.3 7.6 8 8.8 4.7-1.2 8-4.3 8-8.8v-6L12 3Z" stroke="#0049B4" stroke-width="1.8" stroke-linejoin="round"/><path d="m8.8 12.2 2.4 2.4 4.2-4.8" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 05</em>운영 전환</div><p>보안 점검과 계약 절차를 마치면 운영 키가 발급되고 실거래 호출이 시작됩니다.</p></div>
</div>
</div>
</section>
<!-- SECURITY -->
<section class="intro-section" aria-labelledby="intro-security-title">
<span class="intro-section__eyebrow">SECURITY &amp; OPERATION</span>
<h2 class="intro-section__title" id="intro-security-title">보안 및 운영 정책</h2>
<div class="intro-grid3">
<div class="intro-card">
<h3>인증 · 통신</h3>
<p>OAuth2 client_credentials 방식으로 발급한 토큰을 <code>X-AUTH-TOKEN</code> 헤더로 전달합니다. 모든 구간은 TLS 1.2 이상으로 암호화합니다.</p>
</div>
<div class="intro-card">
<h3>접근 통제</h3>
<p>앱 단위로 IP 허용목록과 호출 유량(rate limit)을 적용하여 운영하고 있습니다.</p>
</div>
<div class="intro-card">
<h3>모니터링 · 지원</h3>
<p>포탈에서 호출 이력·응답코드·지연 통계를 조회할 수 있으며, 장애 상황은 API Status 페이지와 등록 메일로 공지합니다.</p>
</div>
</div>
</section>
<!-- CTA -->
<div class="intro-cta">
<div class="intro-cta__body">
<h3>[[${brandName}]] API, 지금 신청하세요</h3>
<p>가입 후 앱을 등록하면 샌드박스 키가 발급되어 바로 개발을 시작할 수 있습니다.</p>
</div>
<a class="btn-action-primary" th:href="@{/signup}">개발자 회원가입</a>
</div>
</section>
</div>
</div>
</th:block>
</body>
@@ -27,7 +27,7 @@
<rect x="160" y="90" width="80" height="50" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="200" y="112" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">DJBank</text>
fill="#0049b4">[[${brandName}]]</text>
<text x="200" y="126" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Open API</text>
@@ -51,7 +51,7 @@
개발 가이드 · 2-Legged · Client Credentials
</span>
<h1 class="oauth2-2legged__hero-title">OAuth2 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank Open API를 호출하기 위한 ClientID/Secret 기반 토큰
<p class="oauth2-2legged__hero-lead">[[${brandName}]] Open API를 호출하기 위한 ClientID/Secret 기반 토큰
발급과<br>Bearer 인증 호출 방법을 단계별로 설명합니다.</p>
</div>
@@ -129,7 +129,7 @@
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF"
stroke="#0049b4" />
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">DJBank Open API</text>
fill="#0049b4">[[${brandName}]] Open API</text>
<line x1="880" y1="72" x2="880" y2="254" stroke="#94A3B8" stroke-dasharray="4 4" />
</g>
@@ -441,7 +441,7 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 DJBank 확장 코드</p>
<p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 [[${brandName}]] 확장 코드</p>
</div>
</div>
</section>
@@ -451,7 +451,7 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
<div class="oauth2-2legged__cta-body">
<span class="oauth2-2legged__cta-eyebrow">EXPLORE</span>
<h2 class="oauth2-2legged__cta-title">API 목록 보러가기</h2>
<p class="oauth2-2legged__cta-desc">사용 가능한 DJBank Open API 카탈로그를 확인하세요.</p>
<p class="oauth2-2legged__cta-desc">사용 가능한 [[${brandName}]] Open API 카탈로그를 확인하세요.</p>
<span class="oauth2-2legged__cta-button">API 목록 →</span>
</div>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
@@ -16,7 +16,7 @@
개발 가이드 · Webhook · HMAC-SHA256
</span>
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
<p class="oauth2-2legged__hero-lead">[[${brandName}]][[${brandNameJosaGa}]] 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
단계별로 설명합니다.</p>
<!-- <div class="oauth2-2legged__hero-chips">
@@ -39,7 +39,7 @@
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">DJBank</text>
fill="#0049b4">[[${brandName}]]</text>
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Webhook</text>
@@ -137,7 +137,7 @@
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE"
stroke="#0049b4" />
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">DJBank Webhook Sender</text>
fill="#0049b4">[[${brandName}]] Webhook Sender</text>
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
</g>
<g>
@@ -175,7 +175,7 @@
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
<span class="oauth2-2legged__eyebrow">STEP 1</span>
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
<p class="oauth2-2legged__desc">[[${brandName}]][[${brandNameJosaEun}]] 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
<div class="oauth2-2legged__endpoint-box">
<span class="oauth2-2legged__method">POST</span>
@@ -210,10 +210,17 @@
<td><code>Content-Type</code></td>
<td>application/json</td>
</tr>
<tr>
<td><code>X-Webhook-Secret</code></td>
<td>Webhook 관리 화면에서 직접 설정한 값 — <strong>설정한 경우에만</strong> 전송(선택)</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
</p>
<p class="oauth2-2legged__warning"><code>X-Webhook-Secret</code>은 서명 검증과 무관합니다.
Webhook 신청/수정 시 입력한 값을 그대로 echo 하는 헤더로, 수신측에서 추가로 값을 대조하고 싶을 때만
사용하세요(값을 설정하지 않았다면 이 헤더는 아예 오지 않습니다).</p>
</div>
<div class="oauth2-2legged__code-panel">
@@ -222,10 +229,11 @@
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>,
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"DB 점검으로 15분간 서비스가 중단됩니다."</span>,
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
}
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
<span class="o2leg-g"># eventId: 발송 건 고유 ID · message: 관리자가 입력한 안내 문구(자유텍스트, 생략될 수 있음) · data: 영향 API 목록</span></pre>
</div>
</div>
</section>
@@ -374,7 +382,7 @@ valid = constantTimeEquals(received, expected)</pre>
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
<span class="oauth2-2legged__eyebrow">STEP 4</span>
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 [[${brandName}]]의 성공 판정과 재시도가 결정됩니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel">
@@ -384,7 +392,7 @@ valid = constantTimeEquals(received, expected)</pre>
<tr>
<th>반환</th>
<th>상황</th>
<th>DJBank 처리</th>
<th>[[${brandName}]] 처리</th>
</tr>
</thead>
<tbody>
@@ -72,13 +72,13 @@
<div class="webhook-info-group">
<span class="group-label">대상 API</span>
<div class="badges-row">
<span class="api-badge" th:each="apiId : *{apiIds}" th:text="${apiId}">API</span>
<span class="api-badge" th:each="api : *{apis}" th:text="${api.name}" th:title="${api.id}">API</span>
</div>
</div>
<!-- Secret Key -->
<!-- 서명검증키 -->
<div class="webhook-info-group">
<span class="group-label">Secret Key</span>
<span class="group-label">서명검증키</span>
<div class="secret-action-row">
<div class="secret-box" id="secretMasked" th:text="*{secretMasked}">************</div>
<th:block sec:authorize="hasRole('ROLE_WEBHOOK')">
@@ -88,6 +88,18 @@
</div>
</div>
<!-- 사용자 지정 Secret -->
<div class="webhook-info-group">
<span class="group-label">사용자 지정 Secret</span>
<div class="secret-action-row" th:if="*{userSecretMasked != null and !#strings.isEmpty(userSecretMasked)}">
<div class="secret-box" id="userSecretMasked" th:text="*{userSecretMasked}">************</div>
<th:block sec:authorize="hasRole('ROLE_WEBHOOK')">
<button type="button" class="btn-reveal-action" id="btnRevealUserSecret">조회</button>
</th:block>
</div>
<div class="input-display-box" th:unless="*{userSecretMasked != null and !#strings.isEmpty(userSecretMasked)}">미설정</div>
</div>
</div>
</div>
@@ -163,11 +175,23 @@
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
// Secret 조회
// 비밀번호 5회 오답 → 서버가 세션을 강제 종료함. 경고 후 로그인 화면으로 이동.
// true 반환 시 호출측은 이후 처리를 중단해야 한다.
function handleForceLogout(res) {
if (res && res.forceLogout) {
alert(res.message || '비밀번호 확인 5회 실패로 로그아웃되었습니다.');
window.location.href = '/login?pwFailExceeded=1';
return true;
}
return false;
}
// 서명검증키 조회
var btnReveal = document.getElementById('btnRevealSecret');
if (btnReveal) btnReveal.addEventListener('click', function () {
openModal('Secret 조회', '비밀번호 확인 후 Secret Key를 표시합니다.', function (pw) {
openModal('서명검증키 조회', '비밀번호 확인 후 서명검증키를 표시합니다.', function (pw) {
post('/webhook/verify-secret', pw).then(function (res) {
if (handleForceLogout(res)) { return; }
if (res.success) {
document.getElementById('secretMasked').textContent = res.secret;
closeModal();
@@ -176,27 +200,43 @@
});
});
// Secret 재발급
// 서명검증키 재발급
var btnRegen = document.getElementById('btnRegenSecret');
if (btnRegen) btnRegen.addEventListener('click', function () {
openModal('Secret 재발급',
'재발급 시 기존 Secret은 즉시 무효화됩니다. 새 Secret을 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
openModal('서명검증키 재발급',
'재발급 시 기존 서명검증키는 즉시 무효화됩니다. 새 서명검증키를 수신 서버의 Webhook 서명검증에 반영하지 않으면 이후 발송되는 모든 Webhook의 서명 검증이 실패합니다. 계속하려면 비밀번호를 입력하세요.',
function (pw) {
post('/webhook/regenerate-secret', pw).then(function (res) {
if (handleForceLogout(res)) { return; }
if (res.success) {
document.getElementById('secretMasked').textContent = res.secret;
closeModal();
alert('Secret이 재발급되었습니다.\n반드시 수신 서버의 서명검증 Secret을 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
alert('서명검증키가 재발급되었습니다.\n반드시 수신 서버의 서명검증 키를 새 값으로 교체하세요.\n교체 전까지 Webhook 서명 검증이 실패합니다.');
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
// 사용자 지정 Secret 조회
var btnRevealUserSecret = document.getElementById('btnRevealUserSecret');
if (btnRevealUserSecret) btnRevealUserSecret.addEventListener('click', function () {
openModal('사용자 지정 Secret 조회', '비밀번호 확인 후 값을 표시합니다.', function (pw) {
post('/webhook/verify-secret', pw).then(function (res) {
if (handleForceLogout(res)) { return; }
if (res.success) {
document.getElementById('userSecretMasked').textContent = res.userSecret || '';
closeModal();
} else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
});
});
// 삭제
var btnDelete = document.getElementById('btnDeleteWebhook');
if (btnDelete) btnDelete.addEventListener('click', function () {
openModal('Webhook 삭제', '삭제하면 복구할 수 없습니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
post('/webhook/delete', pw).then(function (res) {
if (handleForceLogout(res)) { return; }
if (res.success) { window.location.href = '/webhook'; }
else { showError(res.message || '실패했습니다.'); }
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
@@ -106,6 +106,17 @@
<p class="field-help-red">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<!-- 사용자 지정 Secret -->
<div class="s1-field">
<label class="s1-label">사용자 지정 Secret (선택)</label>
<input type="text" id="userSecret" th:field="*{userSecret}" class="s1-input"
placeholder="발송 요청 헤더에 그대로 포함될 값" autocomplete="off" maxlength="500"
pattern="[\x20-\x7E]*" title="ASCII 문자만 입력 가능합니다(한글 등 유니코드 불가).">
<p class="field-error" th:if="${#fields.hasErrors('userSecret')}" th:errors="*{userSecret}">오류</p>
<p class="field-help-red" th:if="${userSecretSet}">현재 값이 설정되어 있습니다. 공란으로 두면 기존 값이 유지되고, 값을 입력하면 교체됩니다. (ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
<p class="field-help" th:unless="${userSecretSet}">입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요. (영문·숫자·특수문자 등 ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
</div>
<!-- 알림 이벤트 -->
<div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
@@ -153,7 +153,7 @@
<div class="s3-message-wrapper">
<h1 class="s3-success-title">Webhook 수정이 완료되었습니다.</h1>
<p class="s3-success-desc">
Secret Key는 변경되지 않았습니다.
서명검증키는 변경되지 않았습니다.
</p>
</div>
</div>
@@ -80,6 +80,16 @@
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
</div>
<!-- 사용자 지정 Secret -->
<div class="s1-field">
<label class="s1-label">사용자 지정 Secret (선택)</label>
<input type="text" id="userSecret" th:field="*{userSecret}" class="s1-input"
placeholder="발송 요청 헤더에 그대로 포함될 값" autocomplete="off" maxlength="500"
pattern="[\x20-\x7E]*" title="ASCII 문자만 입력 가능합니다(한글 등 유니코드 불가).">
<p class="field-error" th:if="${#fields.hasErrors('userSecret')}" th:errors="*{userSecret}">오류</p>
<p class="field-help">입력한 값은 Webhook 발송 시 요청 헤더에 그대로 포함되어 전달됩니다. 수신측 값 검증 용도로 사용하세요. (영문·숫자·특수문자 등 ASCII 문자만 가능, 한글 등 유니코드 불가)</p>
</div>
<!-- 알림 이벤트 -->
<div class="s1-field">
<label class="s1-label">알림 받을 이벤트 <span class="s1-required">*</span></label>
@@ -64,7 +64,7 @@
<div class="webhook-complete">
<div class="complete-icon"></div>
<h3>Webhook이 등록되었습니다</h3>
<p class="field-help">Secret Key는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
<p class="field-help">서명검증키는 [Webhook 관리]에서 비밀번호 확인 후 조회할 수 있습니다.</p>
</div>
</div>
@@ -23,8 +23,10 @@
- 폼 id 고정 "apiSelectorForm" — 제출 버튼은 form="apiSelectorForm" 으로 연결.
- "이전" 버튼은 호출 페이지에 id="btnPrevStep" — 모듈 JS가 data-save-action 경로로 저장 POST 후 step1 복귀.
- 추가 hidden 필드는 호출 페이지에서 form="apiSelectorForm" 속성으로 주입(예: apikey 수정 clientId).
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) 재사용.
- API 목록: GET /apis/for_request (ROLE_API_KEY_REQUEST) AJAX. 카테고리/검색 전환 시 재조회 없이
클라이언트에서 12건/페이지로 페이징(#apiPagination, api-selector.js PAGE_SIZE) — 전체선택/모달은 페이징과
무관하게 필터된 전체 목록 기준으로 동작.
- 스타일: design s2-* (_apikey-register.scss step2 재작업분) + 전역 .pagination(_pagination.scss) 재사용.
*/-->
<th:block th:fragment="apiSelector(apiServices, selectedApis, formAction, saveAction)">
@@ -94,6 +96,9 @@
</div>
</div>
<!-- Pagination (JS 렌더 — fragment/pagination.html 과 동일 마크업/클래스, 전역 CSS 재사용) -->
<div class="pagination" id="apiPagination"></div>
</form>
</main>
</div>
@@ -7,7 +7,7 @@
<div class="footer-content">
<!-- Left Section -->
<div class="footer-left">
<img src="/img/logo/logo-jjb.png" alt="DJBank" class="footer-logo">
<img th:src="@{${brandLogoFooterPath}}" alt="DJBank" class="footer-logo">
<div class="footer-links">
<a th:href="@{/agreements/terms}" class="footer-link">이용약관</a>
<span class="footer-separator"></span>
@@ -22,7 +22,7 @@
<div>
<div class="logo">
<a th:href="@{/}" class="mobile-logo-link">
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
<img th:src="@{${brandLogoHeaderPath}}" alt="DJBank" class="mobile-logo">
</a>
<a th:href="@{/}" class="mobile-logo-text" style="font-size: 22px; font-weight: 700; color: #212529; text-decoration: none; margin-left: 8px; vertical-align: middle;">API Portal</a>
<span th:if="${activeProfileBadge != null}" class="env-badge" th:text="${activeProfileBadge}">dev</span>
@@ -97,7 +97,7 @@
<div class="mobile-header">
<div class="mobile-left">
<a th:href="@{/}" class="mobile-logo-link">
<img src="/img/logo/logo-djb.png" alt="DJBank" class="mobile-logo">
<img th:src="@{${brandLogoHeaderPath}}" alt="DJBank" class="mobile-logo">
</a>
<a th:href="@{/}" class="mobile-logo-text">API Portal</a>
</div>
@@ -140,7 +140,7 @@
<div class="drawer-header">
<div class="drawer-header-left">
<a th:href="@{/}" class="drawer-logo-link">
<img th:src="@{/img/logo/logo-djb.png}" alt="DJBank" class="drawer-logo">
<img th:src="@{${brandLogoHeaderPath}}" alt="DJBank" class="drawer-logo">
</a>
<span class="drawer-logo-text">API Portal</span>
</div>
@@ -161,7 +161,7 @@
<!-- Welcome Section (Anonymous) -->
<div class="drawer-welcome" sec:authorize="isAnonymous()">
<p class="welcome-text">DJBank API Portal에 오신것을 환영합니다.</p>
<p class="welcome-text">[[${brandName}]] API Portal에 오신것을 환영합니다.</p>
<div class="welcome-buttons">
<a th:href="@{/signup}" class="btn-drawer-signup">회원가입</a>
<a th:href="@{/login}" class="btn-drawer-login">로그인</a>
@@ -16,7 +16,7 @@
<!-- 헤더 -->
<div class="tfa-head">
<span class="tfa-head-title" id="tfaTitle">추가 인증</span>
<span class="tfa-head-brand">DJBank</span>
<span class="tfa-head-brand">[[${brandName}]]</span>
</div>
<div class="tfa-body">
@@ -0,0 +1,65 @@
package com.eactive.apim.portal.apps.auth.service;
import com.eactive.apim.portal.portaluser.entity.TwoFactorAuth;
import com.eactive.apim.portal.portaluser.service.AuthNumberException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthNumberServiceImplTest {
private static final Pattern RETRY_SECONDS = Pattern.compile(
"인증번호 재발송 제한이 적용 중입니다\\. (\\d+)초 후 다시 시도해 주세요\\.");
@Mock
private AuthNumberStorage storage;
@Mock
private AuthNumberGenerator generator;
@Mock
private MessageSender messageSender;
private AuthNumberServiceImpl service;
@BeforeEach
void setUp() {
service = new AuthNumberServiceImpl(storage, generator, messageSender);
ReflectionTestUtils.setField(service, "authNumberExpirationTime", 300);
ReflectionTestUtils.setField(service, "resendLimitSeconds", 30);
}
@Test
void resendLimitMessageIncludesRemainingSeconds() {
String recipient = "01099121100";
TwoFactorAuth existing = new TwoFactorAuth(
recipient, "123456", LocalDateTime.now().plusSeconds(300));
when(storage.getAuthNumber(recipient)).thenReturn(Optional.of(existing));
AuthNumberException exception = assertThrows(
AuthNumberException.class,
() -> service.sendRequestAuthNumber(recipient, "SMS")
);
Matcher matcher = RETRY_SECONDS.matcher(exception.getMessage());
assertTrue(matcher.matches(), "남은 재시도 초가 안내 메시지에 포함되어야 함");
long remainingSeconds = Long.parseLong(matcher.group(1));
assertTrue(remainingSeconds >= 1 && remainingSeconds <= 30,
"남은 초는 1~30 범위여야 함: " + remainingSeconds);
assertNull(exception.getReason());
verifyNoInteractions(generator, messageSender);
}
}
@@ -0,0 +1,71 @@
package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.user.service.PasswordService;
import com.eactive.apim.portal.common.dto.PasswordValidationDTO;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.UserPasswordHistory;
import com.eactive.apim.portal.portaluser.repository.PortalUserRepository;
import com.eactive.apim.portal.portaluser.repository.UserPasswordHistoryRepository;
import java.util.Collections;
import java.util.Optional;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PasswordServiceTest {
@Mock
private PortalUserRepository portalUserRepository;
@Mock
private UserPasswordHistoryRepository passwordHistoryRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private LocalValidatorFactoryBean validator;
@InjectMocks
private PasswordService passwordService;
@Test
void rejectsPasswordStoredAgainstPortalUserId() {
PortalUser user = new PortalUser();
user.setId("user-uuid");
user.setLoginId("user@example.com");
user.setMobileNumber("010-1234-5678");
user.setPasswordHash("temporary-password-hash");
UserPasswordHistory previousPassword = new UserPasswordHistory();
previousPassword.setUserId("user-uuid");
previousPassword.setPasswordHash("original-password-hash");
when(portalUserRepository.findByLoginId("user@example.com")).thenReturn(Optional.of(user));
when(passwordEncoder.matches("Original!123", "temporary-password-hash")).thenReturn(false);
when(validator.validate(any(PasswordValidationDTO.class))).thenReturn(Collections.emptySet());
when(passwordHistoryRepository.findRecentPasswordsByUserId("user-uuid"))
.thenReturn(Collections.singletonList(previousPassword));
when(passwordEncoder.matches("Original!123", "original-password-hash")).thenReturn(true);
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> passwordService.updatePassword(
"user@example.com", "Original!123", "Original!123")
);
assertEquals("최근 5회 이내에 사용한 비밀번호는 사용할 수 없습니다.", exception.getMessage());
verify(passwordHistoryRepository).findRecentPasswordsByUserId("user-uuid");
verify(passwordHistoryRepository, never()).findRecentPasswordsByUserId("user@example.com");
verify(portalUserRepository, never()).save(user);
}
}
@@ -0,0 +1,83 @@
package com.eactive.apim.portal.apps.user;
import com.eactive.apim.portal.apps.agreements.service.AgreementsFacade;
import com.eactive.apim.portal.apps.user.facade.MessageRequestFacade;
import com.eactive.apim.portal.apps.user.facade.UserFacadeImpl;
import com.eactive.apim.portal.apps.user.mapper.PortalUserMapper;
import com.eactive.apim.portal.apps.user.service.PasswordService;
import com.eactive.apim.portal.apps.user.service.PortalOrgService;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode;
import com.eactive.apim.portal.template.service.MessageHandlerService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class UserFacadeImplTest {
@Mock
private PortalUserService portalUserService;
@Mock
private PortalOrgService portalOrgService;
@Mock
private PasswordService passwordService;
@Mock
private PortalUserMapper portalUserMapper;
@Mock
private MessageHandlerService messageHandlerService;
@Mock
private AgreementsFacade agreementsFacade;
@Mock
private MessageRequestFacade messageRequestFacade;
@InjectMocks
private UserFacadeImpl userFacade;
@Test
void corporateManagerCannotWithdraw() {
PortalUser manager = new PortalUser();
manager.setId("manager-1");
manager.setRoleCode(RoleCode.ROLE_CORP_MANAGER);
when(portalUserService.findById("manager-1")).thenReturn(manager);
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userFacade.withdrawUser("manager-1", "withdrawal reason")
);
assertEquals(
"법인 관리자는 회원 탈퇴를 할 수 없습니다. "
+ "관리자 권한을 다른 사용자에게 이관하거나 담당자에게 연락해 주세요.",
exception.getMessage()
);
verifyNoInteractions(agreementsFacade, messageRequestFacade);
verify(portalUserService, never()).deleteUser(manager, "withdrawal reason");
}
@Test
void corporateUserCanWithdraw() {
PortalUser user = new PortalUser();
user.setId("user-1");
user.setLoginId("corp-user@example.com");
user.setUserName("법인 사용자");
user.setRoleCode(RoleCode.ROLE_CORP_USER);
when(portalUserService.findById("user-1")).thenReturn(user);
userFacade.withdrawUser("user-1", "withdrawal reason");
verify(agreementsFacade).deleteUserAgreements("user-1");
verify(messageRequestFacade).deleteUserMessage("법인 사용자", "corp-user@example.com");
verify(portalUserService).deleteUser(user, "withdrawal reason");
}
}
@@ -2,6 +2,7 @@ package com.eactive.apim.portal.djb.testcleanup.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -13,15 +14,23 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.eactive.apim.portal.app.repository.CredentialRepository;
import com.eactive.apim.portal.apprequest.entity.AppRequest;
import com.eactive.apim.portal.apprequest.repository.AppRequestRepository;
import com.eactive.apim.portal.approval.entity.Approval;
import com.eactive.apim.portal.approval.statemachine.RequestedState;
import com.eactive.apim.portal.apps.approval.service.ApprovalService;
import com.eactive.apim.portal.apps.community.partnership.repository.PartnershipApplicationRepository;
import com.eactive.apim.portal.apps.community.qna.repository.InquiryRepository;
import com.eactive.apim.portal.apps.user.repository.PortalOrgRepository;
import com.eactive.apim.portal.apps.user.service.PortalUserService;
import com.eactive.apim.portal.djb.community.qna.comment.repository.InquiryCommentRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestApiRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestEventRepository;
import com.eactive.apim.portal.djb.webhook.repository.WebhookRequestRepository;
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
import com.eactive.apim.portal.file.service.FileService;
import com.eactive.apim.portal.invitation.repository.UserInvitationRepository;
import com.eactive.apim.portal.portalorg.entity.PortalOrg;
import com.eactive.apim.portal.portaluser.entity.PortalUser;
import com.eactive.apim.portal.portaluser.repository.PortalUserPrivacyAgreementRepository;
@@ -40,6 +49,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.security.crypto.password.PasswordEncoder;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -56,11 +66,17 @@ class TestCleanupServiceTest {
@Mock private UserPasswordHistoryRepository userPasswordHistoryRepository;
@Mock private UserLogRepository userLogRepository;
@Mock private CredentialRepository credentialRepository;
@Mock private AppRequestRepository appRequestRepository;
@Mock private ApprovalService approvalService;
@Mock private WebhookRequestRepository webhookRequestRepository;
@Mock private WebhookRequestApiRepository webhookRequestApiRepository;
@Mock private WebhookRequestEventRepository webhookRequestEventRepository;
@Mock private WebhookService webhookService;
@Mock private UserInvitationRepository userInvitationRepository;
@Mock private FileService fileService;
@Mock private TestCleanupNativeQueries nativeQueries;
@Mock private PortalUserService portalUserService;
@Mock private PasswordEncoder passwordEncoder;
private TestCleanupService service;
@@ -75,8 +91,10 @@ class TestCleanupServiceTest {
service = new TestCleanupService(environment, portalOrgRepository, portalUserRepository,
inquiryRepository, inquiryCommentRepository, partnershipApplicationRepository,
userRoleHistoryRepository, portalUserPrivacyAgreementRepository, userPasswordHistoryRepository,
userLogRepository, credentialRepository, webhookRequestRepository, webhookRequestApiRepository,
webhookRequestEventRepository, webhookService, nativeQueries);
userLogRepository, credentialRepository, appRequestRepository, approvalService,
webhookRequestRepository, webhookRequestApiRepository,
webhookRequestEventRepository, webhookService, userInvitationRepository, fileService,
nativeQueries, portalUserService, passwordEncoder);
when(environment.acceptsProfiles(Profiles.of("stage", "prod"))).thenReturn(false);
}
@@ -98,6 +116,24 @@ class TestCleanupServiceTest {
verifyNoInteractions(webhookService, portalUserRepository);
}
@Test
void deleteOrgCascade_fallsBackToDigitsOnlyBusinessNumber() {
String hyphenatedCompRegNo = "123-45-67890";
String digitsOnlyCompRegNo = "1234567890";
PortalOrg org = org();
when(portalOrgRepository.findByCompRegNo(hyphenatedCompRegNo)).thenReturn(Optional.empty());
when(portalOrgRepository.findByCompRegNo(digitsOnlyCompRegNo)).thenReturn(Optional.of(org));
when(portalUserRepository.findAllByPortalOrg_Id(ORG_ID)).thenReturn(Collections.emptyList());
when(webhookRequestRepository.findByOrgId(ORG_ID)).thenReturn(Optional.empty());
TestCleanupResult result = service.deleteOrgCascade(hyphenatedCompRegNo);
assertTrue(result.isFound());
assertEquals(ORG_ID, result.getTargetId());
verify(portalOrgRepository).findByCompRegNo(digitsOnlyCompRegNo);
verify(portalOrgRepository).delete(org);
}
@Test
void deleteOrgCascade_found_cascadesUsersAndOrg() {
PortalOrg org = org();
@@ -153,6 +189,88 @@ class TestCleanupServiceTest {
assertTrue(result.getDeletedCounts().isEmpty());
}
@Test
void checkUserExists_found_doesNotDeleteData() {
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(user()));
TestCleanupResult result = service.checkUserExists(EMAIL);
assertTrue(result.isFound());
assertEquals(USER_ID, result.getTargetId());
assertTrue(result.getDeletedCounts().isEmpty());
verify(portalUserRepository, never()).delete(org.mockito.ArgumentMatchers.any(PortalUser.class));
}
@Test
void checkUserExists_notFound() {
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.empty());
TestCleanupResult result = service.checkUserExists(EMAIL);
assertFalse(result.isFound());
assertTrue(result.getDeletedCounts().isEmpty());
}
@Test
void deleteInvitationsByMobile_normalizesAndDeletesAllMatches() {
when(userInvitationRepository.deleteByInvitationMobile("010-1234-5678")).thenReturn(2L);
TestCleanupResult result = service.deleteInvitationsByMobile("01012345678");
assertTrue(result.isFound());
assertEquals("010-1234-5678", result.getTargetId());
assertEquals(2L, result.getDeletedCounts().get("PTL_USER_INVITATION"));
verify(userInvitationRepository).deleteByInvitationMobile("010-1234-5678");
}
@Test
void detachUsersFromOrgByMobile_keepsUserAndRemovesOnlyMembership() {
PortalUser target = user();
target.setPortalOrg(org());
when(portalUserRepository.findAllByMobileNumber("010-1234-5678"))
.thenReturn(Collections.singletonList(target));
TestCleanupResult result = service.detachUsersFromOrgByMobile("01012345678");
assertTrue(result.isFound());
assertEquals(USER_ID, result.getTargetId());
assertEquals(1L, result.getDeletedCounts().get("PTL_USER_ORG_MEMBERSHIP"));
assertNull(target.getPortalOrg());
assertEquals(com.eactive.apim.portal.portaluser.entity.PortalUserEnums.RoleCode.ROLE_USER, target.getRoleCode());
verify(portalUserRepository).save(target);
verify(portalUserRepository, never()).delete(target);
}
@Test
void cancelPendingTestAppRequests_cancelsOnlyExactTestAppRequest() {
PortalUser target = user();
PortalOrg org = org();
target.setPortalOrg(org);
AppRequest request = new AppRequest();
request.setId("APP_REQ_1");
request.setClientName("단위테스트앱-20260824-144907");
Approval approval = new Approval();
approval.setApprovalStatus(new RequestedState());
request.setApproval(approval);
when(portalUserRepository.findPortalUserByEmailAddr(EMAIL)).thenReturn(Optional.of(target));
when(appRequestRepository.findAllByOrgAndClientName(org, request.getClientName()))
.thenReturn(Collections.singletonList(request));
TestCleanupResult result = service.cancelPendingTestAppRequests(EMAIL, request.getClientName());
assertTrue(result.isFound());
assertEquals("APP_REQ_1", result.getTargetId());
assertEquals(1L, result.getDeletedCounts().get("PTL_APP_REQUEST_CANCELLED"));
verify(approvalService).cancelAppApproval(request);
}
@Test
void cancelPendingTestAppRequests_rejectsNonTestName() {
assertThrows(IllegalArgumentException.class,
() -> service.cancelPendingTestAppRequests(EMAIL, "운영앱"));
verifyNoInteractions(appRequestRepository, approvalService);
}
@Test
void deleteUserCascade_found_deletesOrphanDataAndUser() {
PortalUser target = user();