Compare commits
11 Commits
develop
...
a456ed9bb0
| Author | SHA1 | Date | |
|---|---|---|---|
| a456ed9bb0 | |||
| 33c64972f8 | |||
| cff0199d2d | |||
| 2e920eec96 | |||
| 65a262849e | |||
| 7056306718 | |||
| 05e0599b26 | |||
| c223fa3124 | |||
| 78b1edff5a | |||
| b41a81bda3 | |||
| 1ea2c89776 |
+6
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+33
-3
@@ -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) {
|
||||
|
||||
+19
@@ -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";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
|
||||
+6
@@ -13,4 +13,10 @@ public interface PartnershipApplicationFacade {
|
||||
* 현재 로그인 사용자가 작성한 최근 3건을 조회한다. 미인증이면 빈 목록.
|
||||
*/
|
||||
List<PartnershipApplicationSummaryDTO> getMyRecentApplications();
|
||||
|
||||
/**
|
||||
* 현재 로그인 사용자가 작성한 글 1건을 삭제한다(첨부파일 포함).
|
||||
* 본인 글이 아니거나 이미 삭제된 경우 {@link IllegalArgumentException}.
|
||||
*/
|
||||
void deleteMyApplication(String id);
|
||||
}
|
||||
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+41
-6
@@ -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에 저장 후 반환
|
||||
|
||||
+2
-2
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+4
@@ -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);
|
||||
}
|
||||
|
||||
+260
-3
@@ -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);
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
|
||||
+286
-3
@@ -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()));
|
||||
|
||||
+66
-19
@@ -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);
|
||||
|
||||
+4
@@ -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,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
+1
-1
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 |
@@ -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>“제주를 더 가깝고, 더 편리하게 — 당신의 설렘을 담은 은행”</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 & 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">
|
||||
|
||||
+65
@@ -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");
|
||||
}
|
||||
}
|
||||
+120
-2
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user