2 Commits

Author SHA1 Message Date
Rinjae a456ed9bb0 사용자 Secret 관리 기능 추가:
eapim-portal CI / build (push) Has been cancelled
eapim-portal Test / test (push) Has been cancelled
- 사용자 지정 Secret 필드 추가(WebhookRequest, DTO, Mapper)
- 사용자 Secret 평문 조회/갱신 로직 및 비밀번호 재인증 처리
- Secret 입력 UI 개선 및 스타일 수정
2026-08-25 15:16:57 +09:00
Rinjae 33c64972f8 - 브랜드명 템플릿화 적용 - 정적 리소스/HTML 다수 수정
- 서비스 소개 페이지 디자인/스타일 대폭 개선
- 웹훅/OAuth2 개발 가이드 문구 및 텍스트 업데이트/템플릿화
2026-08-25 13:42:30 +09:00
41 changed files with 1327 additions and 329 deletions
@@ -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)에서 조회.
*/
@@ -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;
@@ -16,6 +16,7 @@ public class WebhookDTO implements Serializable {
private Long id;
private String targetUrl;
private String secretMasked;
private String userSecretMasked;
private String createdDate;
/** 구독 API ID 목록(폼 prefill 등 내부용). */
@@ -3,6 +3,7 @@ package com.eactive.apim.portal.djb.webhook.dto;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import javax.validation.constraints.Pattern;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import org.hibernate.validator.constraints.NotBlank;
@@ -27,6 +28,15 @@ public class WebhookRegistrationDTO implements Serializable {
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
private String targetUrl;
/**
* 사용자 지정 Secret(선택). Webhook 발송 시 HTTP 요청 헤더 값으로 그대로 echo 되므로
* 출력 가능 ASCII(0x20~0x7E)만 허용한다 — 헤더는 non-ASCII/개행을 담을 수 없다.
* 수정 시 공란이면 기존 값을 유지한다({@code WebhookService#update} 참조).
*/
@Length(max = 500, message = "값은 500자를 초과할 수 없습니다.")
@Pattern(regexp = "^[\\x20-\\x7E]*$", message = "영문·숫자·특수문자 등 ASCII 문자만 입력 가능합니다(한글 등 유니코드 문자는 사용할 수 없습니다).")
private String userSecret;
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
private List<String> eventTypes = new ArrayList<>();
@@ -13,6 +13,7 @@ import org.mapstruct.Mapping;
public interface WebhookMapper {
@Mapping(target = "secretMasked", ignore = true)
@Mapping(target = "userSecretMasked", ignore = true)
@Mapping(target = "apiIds", ignore = true)
@Mapping(target = "eventTypes", ignore = true)
WebhookDTO toDto(WebhookRequest entity);
@@ -48,6 +48,10 @@ public class WebhookRequest implements Serializable {
@Column(name = "SECRET", length = 500)
private String secret;
/** 사용자가 지정한 값. admin 발송 시 요청 헤더에 그대로 echo 된다 — SECRET 과 동일 이유로 평문 저장. */
@Column(name = "USER_SECRET", length = 500)
private String userSecret;
@Column(name = "CREATED_BY", length = 200)
private String createdBy;
@@ -74,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);
@@ -83,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);
@@ -132,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) {
@@ -168,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();
@@ -178,6 +200,8 @@ 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);
List<String> apiIds = apiRepository.findByWebhookReqId(request.getId()).stream()
.map(WebhookRequestApi::getApiId)
+343 -122
View File
@@ -908,7 +908,7 @@ hr {
}
.logo img {
height: 32px;
width: 114px;
width: auto;
display: block;
}
@@ -1247,7 +1247,7 @@ hr {
transition: all 0.3s ease;
}
.mobile-drawer .drawer-welcome .btn-drawer-login:hover {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.mobile-drawer .drawer-welcome.authenticated {
flex-direction: row;
@@ -2559,7 +2559,7 @@ hr {
color: #FFFFFF;
}
.btn-success:hover {
background: rgb(83.2897959184, 199.3102040816, 106.493877551);
background: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
transform: translateY(-3px);
}
.btn-danger {
@@ -2567,7 +2567,7 @@ hr {
color: #FFFFFF;
}
.btn-danger:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-3px);
}
.btn-ghost {
@@ -2833,7 +2833,7 @@ hr {
.action-btn-delete:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.action-btn-delete:active {
transform: translateY(0);
@@ -2951,7 +2951,7 @@ hr {
background: #a4d6ea;
}
.btn-input-action.btn-change:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
}
@@ -3026,7 +3026,7 @@ hr {
border: none;
}
.btn-action-primary:hover {
background: rgb(31.8731707317, 92.7219512195, 205.7268292683);
background: rgb(12.4992826399%, 36.3615494978%, 80.6771879484%);
transform: translateY(-2px);
color: #fff;
}
@@ -3076,7 +3076,7 @@ hr {
}
.status-badge.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.status-badge.status-failed {
background: rgba(255, 107, 107, 0.1);
@@ -3116,7 +3116,7 @@ hr {
}
.status-badge-header.status-processing {
background: rgba(255, 217, 61, 0.1);
color: rgb(221.2, 177.8721649485, 0);
color: rgb(86.7450980392%, 69.7537901759%, 0%);
}
.badge-sm {
@@ -4304,7 +4304,7 @@ select.form-control {
.file-upload-wrapper .file-remove-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(75, 155, 255, 0.1);
background: rgb(255, 88.9, 88.9);
background: rgb(100%, 34.862745098%, 34.862745098%);
}
.file-upload-wrapper .file-remove-btn:active {
transform: translateY(0);
@@ -4625,7 +4625,7 @@ select.form-control {
transition: all 0.3s ease;
}
.form-actions--with-withdrawal .withdrawal-link:hover {
background: rgb(210.2090909091, 232.6045454545, 242.9409090909);
background: rgb(82.4349376114%, 91.2174688057%, 95.2709447415%);
}
.form-actions--with-withdrawal .withdrawal-link img {
width: 22px;
@@ -4780,7 +4780,7 @@ select.form-control {
text-decoration: underline;
}
.notice-content-box a:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.form-row--content .form-label-wrapper {
@@ -5716,7 +5716,7 @@ select.form-control {
font-size: 16px;
}
.drawer-logout-btn:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(75, 155, 255, 0.15);
}
@@ -6429,7 +6429,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--default:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--primary {
background-color: #ecf0fa;
@@ -6437,7 +6437,7 @@ select.form-control {
color: #2a69de;
}
.list-table-btn--primary:hover {
background-color: rgb(216.7625, 224.8125, 244.9375);
background-color: rgb(85.0049019608%, 88.1617647059%, 96.0539215686%);
}
.list-table-btn--secondary {
background-color: #f5f5f4;
@@ -6445,7 +6445,7 @@ select.form-control {
color: #64748b;
}
.list-table-btn--secondary:hover {
background-color: rgb(233.3571428571, 233.3571428571, 231.1928571429);
background-color: rgb(91.512605042%, 91.512605042%, 90.6638655462%);
}
.list-table-btn--danger {
background-color: #fbe7e9;
@@ -6453,7 +6453,7 @@ select.form-control {
color: #bb1026;
}
.list-table-btn--danger:hover {
background-color: rgb(247.5571428571, 210.3428571429, 214.0642857143);
background-color: rgb(97.081232493%, 82.487394958%, 83.9467787115%);
}
.table-pagination {
@@ -7103,7 +7103,7 @@ select.form-control {
.alert.alert-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid rgba(255, 107, 107, 0.3);
color: rgb(255, 70.8, 70.8);
color: rgb(100%, 27.7647058824%, 27.7647058824%);
align-items: center;
}
.alert.alert-error svg {
@@ -7117,7 +7117,7 @@ select.form-control {
.alert.alert-success {
background: rgba(107, 207, 127, 0.1);
border: 1px solid rgba(107, 207, 127, 0.3);
color: rgb(61.5183673469, 189.6816326531, 87.1510204082);
color: rgb(24.12484994%, 74.3849539816%, 34.1768707483%);
}
.alert.alert-info {
background: rgba(0, 73, 180, 0.1);
@@ -11488,10 +11488,10 @@ body.index-page-body {
line-height: 20px;
}
.login-button:hover {
background: rgb(25.65, 70.3, 173.85);
background: rgb(10.0588235294%, 27.568627451%, 68.1764705882%);
}
.login-button:active {
background: rgb(24.3, 66.6, 164.7);
background: rgb(9.5294117647%, 26.1176470588%, 64.5882352941%);
}
.login-button:disabled {
opacity: 0.6;
@@ -11534,10 +11534,10 @@ body.index-page-body {
border-bottom-right-radius: 8px;
}
.login-links-container .link-btn:hover {
background: rgb(220.61, 227.85, 245.95);
background: rgb(86.5137254902%, 89.3529411765%, 96.4509803922%);
}
.login-links-container .link-btn:active {
background: rgb(205.22, 215.7, 241.9);
background: rgb(80.4784313725%, 84.5882352941%, 94.862745098%);
}
.login-alert {
@@ -12046,12 +12046,12 @@ body.index-page-body {
}
.auth-request-button:hover,
.auth-verify-button:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
transform: none !important;
}
.auth-request-button:active,
.auth-verify-button:active {
background: rgb(21.411588785, 146.3125233645, 233.148411215);
background: rgb(8.3967014843%, 57.3774601429%, 91.4307494961%);
}
.auth-request-button:disabled,
.auth-verify-button:disabled {
@@ -12100,10 +12100,10 @@ body.index-page-body {
background: #f0f2f5;
}
.account-recovery-card .form-actions .cancel-button:hover {
background: rgb(225.45, 229.39, 235.3);
background: rgb(88.4117647059%, 89.9568627451%, 92.2745098039%);
}
.account-recovery-card .form-actions .cancel-button:active {
background: rgb(210.9, 216.78, 225.6);
background: rgb(82.7058823529%, 85.0117647059%, 88.4705882353%);
}
.account-recovery-card .form-actions .submit-button {
color: #FFFFFF;
@@ -12113,7 +12113,7 @@ body.index-page-body {
background: rgb(6, 54, 125);
}
.account-recovery-card .form-actions .submit-button:active {
background: rgb(0, 65.7, 162);
background: rgb(0%, 25.7647058824%, 63.5294117647%);
}
.account-recovery-card .form-actions .submit-button:disabled {
opacity: 0.6;
@@ -12349,7 +12349,7 @@ body.index-page-body {
transition: color 0.3s ease;
}
.result-info-box .info-text .info-link:hover {
color: rgb(0, 65.7, 162);
color: rgb(0%, 25.7647058824%, 63.5294117647%);
}
@media (max-width: 576px) {
.result-info-box .info-text {
@@ -17549,7 +17549,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
@media (max-width: 768px) {
.btn-copy-action {
@@ -17576,7 +17576,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
.btn-view-secret svg {
width: 20px;
@@ -17761,7 +17761,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border-radius: 8px;
}
.btn-copy-action:hover {
background: rgb(131.6625, 199.4303571429, 226.5375);
background: rgb(51.6323529412%, 78.2079831933%, 88.8382352941%);
}
.btn-view-secret {
width: 100% !important;
@@ -17777,7 +17777,7 @@ input[type=checkbox]:checked + .custom-checkbox {
height: 16px;
}
.btn-view-secret:hover {
background: rgb(31.8897196262, 151.4130841121, 234.5102803738);
background: rgb(12.5057724024%, 59.377680044%, 91.9648158329%);
}
#revealedSecretBox {
width: 100%;
@@ -18050,7 +18050,7 @@ input[type=checkbox]:checked + .custom-checkbox {
flex-shrink: 0;
}
.detail-wrap .dt-btn-copy:hover {
background: rgb(189.6, 230.0857142857, 255);
background: rgb(74.3529411765%, 90.2296918768%, 100%);
}
.detail-wrap .dt-btn-copy svg {
color: #2a69de;
@@ -18231,7 +18231,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-gray:hover {
background: rgb(170.6589473684, 183.4378947368, 193.6610526316);
background: rgb(66.9250773994%, 71.9364293086%, 75.9455108359%);
}
.detail-wrap .dt-btn-red {
width: 156px;
@@ -18249,7 +18249,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: background 0.2s ease;
}
.detail-wrap .dt-btn-red:hover {
background: rgb(255, 70.0915337423, 64.24);
background: rgb(100%, 27.4868759774%, 25.1921568627%);
}
.detail-wrap .dt-btn-blue {
width: 156px;
@@ -19823,7 +19823,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-list:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.btn-inquiry-list:active {
transform: scale(0.98);
@@ -19856,7 +19856,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-edit:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.btn-inquiry-edit:active {
transform: scale(0.98);
@@ -19889,7 +19889,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.btn-inquiry-delete:hover {
background: rgb(217.9841772152, 41.3658227848, 58.2873417722);
background: rgb(85.4839910648%, 16.2218912882%, 22.8577810871%);
}
.btn-inquiry-delete:active {
transform: scale(0.98);
@@ -19961,7 +19961,7 @@ input[type=checkbox]:checked + .custom-checkbox {
margin-left: 8px;
}
.file-upload-inline .btn-remove-file-inline:hover {
background: rgb(209.4151898734, 36.2848101266, 52.8721518987);
background: rgb(82.1236038719%, 14.2293373045%, 20.7341772152%);
}
.file-upload-inline .btn-remove-file-inline svg {
width: 12px;
@@ -19993,7 +19993,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
}
.file-upload-inline .btn-file-attach:hover {
background: rgb(37.3117757009, 153.9304672897, 235.0082242991);
background: rgb(14.6320689023%, 60.3648891332%, 92.1600879604%);
}
.file-upload-inline .btn-file-attach svg {
width: 22px;
@@ -20053,7 +20053,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-secondary:hover {
background: rgb(215.8869565217, 218.8956521739, 224.9130434783);
background: rgb(84.6615515772%, 85.8414322251%, 88.2011935209%);
}
.inquiry-form-container .form-actions .btn-primary {
background: #0049b4;
@@ -20061,7 +20061,7 @@ input[type=checkbox]:checked + .custom-checkbox {
border: none;
}
.inquiry-form-container .form-actions .btn-primary:hover {
background: rgb(0, 69.35, 171);
background: rgb(0%, 27.1960784314%, 67.0588235294%);
}
.inquiry-form-container .file-upload-inline .file-input-display {
min-height: 50px;
@@ -20850,7 +20850,7 @@ input[type=checkbox]:checked + .custom-checkbox {
cursor: pointer;
}
.djb-board-write-container .form-actions .btn-submit:hover {
background-color: rgb(33.643902439, 97.8731707317, 217.156097561);
background-color: rgb(13.193687231%, 38.3816355811%, 85.1592539455%);
}
@media (max-width: 768px) {
.djb-board-write-container .form-actions .btn-submit {
@@ -21386,7 +21386,7 @@ input[type=checkbox]:checked + .custom-checkbox {
transition: all 0.3s ease;
}
.org-file-remove:hover {
background: rgb(255, 70.8, 70.8);
background: rgb(100%, 27.7647058824%, 27.7647058824%);
}
.org-file-notice {
@@ -22427,7 +22427,7 @@ input[type=checkbox]:checked + .custom-checkbox {
}
.status-indicator.status-active {
background-color: rgba(107, 207, 127, 0.1);
color: rgb(83.2897959184, 199.3102040816, 106.493877551);
color: rgb(32.662665066%, 78.1608643457%, 41.762304922%);
}
.status-indicator.status-active .status-dot {
background-color: #6BCF7F;
@@ -23386,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 {
@@ -26189,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%;
}
@@ -26200,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
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+136 -49
View File
@@ -8,11 +8,13 @@
* - 기선택: window.API_SELECTOR_SELECTED / 목록 URL: window.API_SELECTOR_LIST_URL (fragment 인라인 주입)
* - "이전" 버튼: 호출 페이지의 #btnPrevStep (없으면 스킵)
* - 카트/모달: fragment `apiSelectorPopups` 를 pagePopups 슬롯에서 호출(body 직속)
* - 페이징: #apiPagination (PAGE_SIZE 건/페이지) — 카테고리/검색은 재조회 없이 클라이언트에서 처리
*
* design(figma s2) 인라인 스크립트 대비 패치 3건:
* design(figma s2) 인라인 스크립트 대비 패치 4건:
* 1) 모달 열 때마다 updateModalList() 재빌드 — 세션 복원 직후(카드 렌더 전) 빈 모달 방지
* 2) 모달 리스트를 DOM 체크박스가 아닌 selectedApis Set 기준으로 생성 — 미렌더/타 카테고리 누락 방지
* 3) 제출/이전 시 DOM에 없는 선택분을 hidden input으로 주입 — 카테고리 필터 상태 전송 유실 방지
* 4) 클라이언트 페이징 — 카드는 현재 페이지분만 DOM 렌더, 검색/전체선택/모달은 필터된 전체 목록 기준으로 동작
*/
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('apiSelectorForm');
@@ -20,16 +22,21 @@ document.addEventListener('DOMContentLoaded', function() {
return; // 모듈 미사용 페이지
}
const PAGE_SIZE = 12;
// DOM Elements
const searchInput = document.getElementById('apiSearch');
const menuTitles = document.querySelectorAll('.s2-category-tab');
const apiCardGrid = document.getElementById('apiCardGrid');
const loadingState = document.getElementById('loadingState');
const emptyState = document.getElementById('emptyState');
const paginationEl = document.getElementById('apiPagination');
let currentFilter = ''; // Empty string means "all"
let currentServiceName = '전체';
let allApis = [];
let allApis = []; // 현재 카테고리 조회 결과 전체
let filteredApis = []; // allApis 에 검색어까지 적용한 결과(페이징 대상)
let currentPage = 1;
let selectedApis = new Set();
// Restore selected APIs from session (fragment 인라인 주입)
@@ -44,8 +51,7 @@ document.addEventListener('DOMContentLoaded', function() {
function loadApis(groupId) {
loadingState.style.display = 'block';
emptyState.style.display = 'none';
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
clearCards();
const baseUrl = window.API_SELECTOR_LIST_URL || '/apis/for_request';
let url = baseUrl;
@@ -56,16 +62,7 @@ document.addEventListener('DOMContentLoaded', function() {
fetch(url).then(response => response.json()).then(apis => {
allApis = apis;
loadingState.style.display = 'none';
if (apis.length === 0) {
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
return;
}
document.getElementById('apiResultCount').textContent = apis.length;
renderApiCards(apis);
updateSelectAllUI();
applySearch();
}).catch(error => {
console.error('Failed to load APIs:', error);
loadingState.style.display = 'none';
@@ -73,10 +70,49 @@ document.addEventListener('DOMContentLoaded', function() {
emptyState.querySelector('p').textContent = '다시 시도해주세요.';
emptyState.style.display = 'block';
document.getElementById('apiResultCount').textContent = '0';
renderPagination(0);
});
}
// Render API cards
// 렌더된 카드만 제거(로딩/빈 상태 엘리먼트는 유지)
function clearCards() {
document.querySelectorAll('.s2-api-card').forEach(card => card.remove());
}
// 검색어 기준으로 allApis → filteredApis 재계산 후 1페이지부터 렌더
function applySearch() {
const term = searchInput ? searchInput.value.toLowerCase().trim() : '';
filteredApis = !term ? allApis : allApis.filter(function(api) {
const name = (api.apiName || '').toLowerCase();
const desc = (api.apiSimpleDescription || '').toLowerCase();
return name.includes(term) || desc.includes(term);
});
goToPage(1);
}
// 지정 페이지로 이동 — 해당 페이지분만 렌더(재조회 없음)
function goToPage(page) {
const totalPages = Math.max(1, Math.ceil(filteredApis.length / PAGE_SIZE));
currentPage = Math.min(Math.max(1, page), totalPages);
clearCards();
document.getElementById('apiResultCount').textContent = filteredApis.length;
if (filteredApis.length === 0) {
emptyState.style.display = 'block';
renderPagination(0);
updateSelectAllUI();
return;
}
emptyState.style.display = 'none';
const start = (currentPage - 1) * PAGE_SIZE;
renderApiCards(filteredApis.slice(start, start + PAGE_SIZE));
renderPagination(filteredApis.length);
updateSelectAllUI();
}
// Render API cards (현재 페이지분)
function renderApiCards(apis) {
const fragment = document.createDocumentFragment();
@@ -207,24 +243,25 @@ document.addEventListener('DOMContentLoaded', function() {
updateSelectAllCheckboxState();
}
// Update select all checkbox state based on visible cards
// Update select all checkbox state — 현재 페이지가 아닌 필터된 전체 목록 기준(페이징 무관)
function updateSelectAllCheckboxState() {
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
if (!selectAllCheckbox) {
return;
}
if (visibleCards.length === 0) {
if (filteredApis.length === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
return;
}
const visibleCheckboxes = visibleCards.map(card => card.querySelector('.s2-api-checkbox'));
const checkedCount = visibleCheckboxes.filter(cb => cb.checked).length;
const checkedCount = filteredApis.filter(api => selectedApis.has(api.apiId)).length;
if (checkedCount === 0) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
} else if (checkedCount === visibleCheckboxes.length) {
} else if (checkedCount === filteredApis.length) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
} else {
@@ -233,7 +270,7 @@ document.addEventListener('DOMContentLoaded', function() {
}
}
// Update modal selected APIs list — selectedApis Set 기준 (패치 2)
// Update modal selected APIs list — selectedApis Set 기준(패치 2), 이름은 allApis 우선 조회(패치 4)
function updateModalList() {
const modalSelectedList = document.getElementById('modalSelectedList');
modalSelectedList.innerHTML = '';
@@ -244,8 +281,10 @@ document.addEventListener('DOMContentLoaded', function() {
}
selectedApis.forEach(function(apiId) {
const apiData = allApis.find(a => a.apiId === apiId);
const card = document.querySelector('.s2-api-card[data-api-id="' + apiId + '"]');
const apiName = card ? card.querySelector('.s2-api-card-title').textContent : apiId;
const apiName = apiData ? apiData.apiName
: (card ? card.querySelector('.s2-api-card-title').textContent : apiId);
const apiPill = document.createElement('div');
apiPill.className = 's2-api-pill';
@@ -270,28 +309,69 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// Search functionality
// Pagination 컨트롤 렌더 — fragment/pagination.html 과 동일 마크업/클래스 재사용(전역 _pagination.scss 적용)
function renderPagination(totalItems) {
if (!paginationEl) {
return;
}
paginationEl.innerHTML = '';
const totalPages = Math.ceil(totalItems / PAGE_SIZE);
if (totalPages <= 1) {
return;
}
const ICON_FIRST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0 19V5H2.30769V19H0ZM15 19L4.61538 12L15 5V19Z" fill="currentColor"/><path d="M24 19L15 12L24 5V19Z" fill="currentColor"/></svg><span class="blind">처음 페이지</span>';
const ICON_PREV = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 5L16 19L5 12L16 5Z" fill="currentColor"/></svg><span class="blind">이전 페이지</span>';
const ICON_NEXT = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M8 19V5L19 12L8 19Z" fill="currentColor"/></svg><span class="blind">다음 페이지</span>';
const ICON_LAST = '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M24 5L24 19L21.6923 19L21.6923 5L24 5ZM9 5L19.3846 12L9 19L9 5Z" fill="currentColor"/><path d="M1.22392e-06 5L9 12L0 19L1.22392e-06 5Z" fill="currentColor"/></svg><span class="blind">마지막 페이지</span>';
function navLink(cls, iconHtml, targetPage, disabled) {
const a = document.createElement('a');
a.href = '#';
a.className = cls + (disabled ? ' disabled' : '');
a.innerHTML = iconHtml;
if (!disabled) {
a.addEventListener('click', function(e) {
e.preventDefault();
goToPage(targetPage);
});
}
return a;
}
function numLink(p) {
const a = document.createElement('a');
a.href = '#';
const isCurrent = p === currentPage;
a.className = 'page-num' + (isCurrent ? ' page-current' : '');
a.textContent = String(p);
if (!isCurrent) {
a.addEventListener('click', function(e) {
e.preventDefault();
goToPage(p);
});
}
return a;
}
paginationEl.appendChild(navLink('page-first', ICON_FIRST, 1, currentPage === 1));
paginationEl.appendChild(navLink('page-prev', ICON_PREV, currentPage - 1, currentPage === 1));
const windowStart = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
const windowEnd = Math.min(totalPages, windowStart + 4);
for (let p = Math.max(1, windowStart); p <= windowEnd; p++) {
paginationEl.appendChild(numLink(p));
}
paginationEl.appendChild(navLink('page-next', ICON_NEXT, currentPage + 1, currentPage === totalPages));
paginationEl.appendChild(navLink('page-last', ICON_LAST, totalPages, currentPage === totalPages));
}
// Search functionality — 클라이언트 필터(재조회 없음), 필터 변경 시 1페이지로 리셋
if (searchInput) {
searchInput.addEventListener('input', function() {
const searchTerm = this.value.toLowerCase();
const apiCards = document.querySelectorAll('.s2-api-card');
let visibleCount = 0;
apiCards.forEach(function(card) {
const apiName = card.getAttribute('data-name');
const apiDesc = card.getAttribute('data-desc');
const matchesSearch = apiName.includes(searchTerm) || apiDesc.includes(searchTerm);
if (matchesSearch) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
document.getElementById('apiResultCount').textContent = visibleCount;
updateSelectAllCheckboxState();
applySearch();
});
}
@@ -307,11 +387,10 @@ document.addEventListener('DOMContentLoaded', function() {
currentFilter = groupId;
currentServiceName = this.textContent.trim();
loadApis(groupId);
if (searchInput) {
searchInput.value = '';
}
loadApis(groupId);
});
});
@@ -398,18 +477,26 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// Select All checkbox event
// Select All checkbox event — 현재 페이지가 아닌 필터된 전체 목록 대상(페이징 무관)
const selectAllCheckbox = document.getElementById('selectAllCheckbox');
if (selectAllCheckbox) {
selectAllCheckbox.addEventListener('change', function() {
const isChecked = this.checked;
const visibleCards = Array.from(document.querySelectorAll('.s2-api-card')).filter(card => card.style.display !== 'none');
visibleCards.forEach(function(card) {
filteredApis.forEach(function(api) {
if (isChecked) {
selectedApis.add(api.apiId);
} else {
selectedApis.delete(api.apiId);
}
});
// 현재 페이지에 실제 렌더된 카드만 체크 상태 동기화
document.querySelectorAll('.s2-api-card').forEach(function(card) {
const checkbox = card.querySelector('.s2-api-checkbox');
if (checkbox) {
checkbox.checked = isChecked;
updateCardSelection(checkbox);
card.classList.toggle('selected', isChecked);
}
});
@@ -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 {
@@ -245,7 +246,7 @@
img {
height: 32px;
width: 114px;
width: auto;
display: block;
}
}
@@ -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>
@@ -89,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>
@@ -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 -->
@@ -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">
@@ -17,7 +17,7 @@
</svg>
<h2 class="signup-title">회원가입</h2>
</div>
<p class="signup-message">DJBank API Portal 사용을 위해 회원 가입해 주세요.</p>
<p class="signup-message">[[${brandName}]] API Portal 사용을 위해 회원 가입해 주세요.</p>
</div>
<!-- Signup Cards -->
@@ -28,7 +28,7 @@
</p>
<p class="info-text" style="margin-top: 16px;">
<strong th:text="${orgName}"></strong>에서
DJBank API Portal 법인회원으로 초대하였습니다.
[[${brandName}]] API Portal 법인회원으로 초대하였습니다.
</p>
</div>
@@ -38,7 +38,7 @@
<span th:text="${userName}"></span>님, 법인회원 초대가 도착했습니다.
</strong>
<p>
<strong><span th:text="${orgName}"></span></strong>에서 DJBank API Portal 법인회원으로 초대하였습니다.<br>
<strong><span th:text="${orgName}"></span></strong>에서 [[${brandName}]] API Portal 법인회원으로 초대하였습니다.<br>
초대를 수락하시면 법인회원으로 전환됩니다.
</p>
</div>
@@ -18,7 +18,7 @@
<span class="service-hero__badge-text">회원가입 소개</span>
</div>
<h1 class="service-hero__title">회원 가입 안내</h1>
<p class="service-hero__desc">DJ Bank API 개발자 포털에 방문해 주셔서 감사합니다.<br>DJBank API 사용을 위해서는 다음과 같은
<p class="service-hero__desc">[[${brandName}]] API 개발자 포털에 방문해 주셔서 감사합니다.<br>[[${brandName}]] API 사용을 위해서는 다음과 같은
이용절차로 진행하여야 합니다.</p>
</div>
</div>
@@ -5,9 +5,235 @@
<body>
<th:block layout:fragment="contentFragment">
<section class="service-intro">
<h1 class="service-intro__title">DJBank API Portal 소개</h1>
</section>
<div class="service-intro">
<!-- Hero -->
<section class="service-hero">
<div class="service-hero__inner">
<div class="service-hero__icon-wrapper">
<svg width="230" height="167" viewBox="0 0 300 200" fill="none" aria-hidden="true">
<ellipse cx="150" cy="178" rx="98" ry="14" fill="#0B2A5B" opacity=".88"/>
<path d="M44 96h212" stroke="#0049B4" stroke-width="6" stroke-linecap="round"/>
<path d="M60 96v58h180V96" stroke="#0049B4" stroke-width="6" stroke-linejoin="round"/>
<path d="M40 96 150 38l110 58" stroke="#0B2A5B" stroke-width="7" stroke-linejoin="round" fill="#fff"/>
<rect x="84" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="118" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="168" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<rect x="202" y="106" width="14" height="40" rx="6" fill="#00ACDD"/>
<circle cx="150" cy="74" r="13" fill="#F08A24"/>
<path d="M144 74h12M150 68v12" stroke="#fff" stroke-width="3" stroke-linecap="round"/>
<path d="M22 140h16m-8-8v16" stroke="#B9D3EC" stroke-width="5" stroke-linecap="round"/>
<path d="M262 140h16m-8-8v16" stroke="#B9D3EC" stroke-width="5" stroke-linecap="round"/>
</svg>
</div>
<div class="service-hero__content">
<div class="service-hero__badge">
<span class="service-hero__badge-dot"></span>
<span class="service-hero__badge-text">서비스 소개</span>
</div>
<h1 class="service-hero__title">[[${brandName}]]의 금융을<br>API로 연결합니다</h1>
<p class="service-hero__desc">
1969년 제주에서 시작해 신한금융그룹과 함께 성장해 온 [[${brandName}]][[${brandNameJosaGa}]]<br>
인증·조회·이체·기업여신 서비스를 표준 오픈 API로 개방합니다.
</p>
</div>
</div>
</section>
<div class="container service-main">
<th:block th:replace="~{fragment/djbank/service_sidebar :: sidebar('intro')}"></th:block>
<section class="service-content">
<!-- Callout -->
<div class="intro-callout">
<div class="intro-callout__icon">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none"><path d="m6 13.4 5 5 9.5-11" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<ul>
<li>[[${brandName}]] API 포탈은 <strong>핀테크·법인·솔루션 사업자</strong>가 [[${brandName}]] 금융 서비스를 연동하는 <strong>공식 파트너 채널</strong>입니다.</li>
<li>API 명세·샌드박스·이용 신청·호출 이력을 <strong>한 곳에서</strong> 제공하며, 모든 호출은 <strong>OAuth2 기반 인증</strong>으로 보호됩니다.</li>
<li>이용은 <strong>회원가입 → 심사·승인 → 앱 등록 → 테스트 → 운영 전환</strong> 순으로 진행됩니다.</li>
</ul>
</div>
<!-- ABOUT -->
<section class="intro-section" aria-labelledby="intro-about-title">
<span class="intro-section__eyebrow">ABOUT PORTAL</span>
<h2 class="intro-section__title" id="intro-about-title">[[${brandName}]] 오픈 API 포탈이란</h2>
<p class="intro-section__lead">
1969년 설립된 제주은행은 반세기 넘게 지역 경제의 중추 역할을 해왔고, 신한금융지주회사의 자회사 편입 이후
디지털 전환에 속도를 내고 있습니다. 은행의 비전인 <strong>&ldquo;제주를 더 가깝고, 더 편리하게 &mdash; 당신의 설렘을 담은 은행&rdquo;</strong>
창구를 넘어 고객이 이미 사용하는 서비스 안으로 금융을 옮기는 일에서 시작합니다.<br><br>
API 포탈은 그 실행 도구입니다. 계좌 조회와 이체 같은 기본 뱅킹부터 기업여신·수납·알림까지,
내부에서만 쓰이던 금융 기능을 표준 REST API와 웹훅으로 정리해 외부 파트너에게 개방합니다.
문서·샌드박스·키 관리·모니터링을 하나의 화면에서 제공하므로, 별도 협의 없이도 연동 설계를 먼저 시작할 수 있습니다.
</p>
</section>
<!-- WHO -->
<section class="intro-section" aria-labelledby="intro-who-title">
<span class="intro-section__eyebrow">FOR WHOM</span>
<h2 class="intro-section__title" id="intro-who-title">이런 분들이 이용합니다</h2>
<div class="intro-who">
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><rect x="3" y="5" width="18" height="14" rx="3" stroke="#0049B4" stroke-width="1.8"/><path d="M8 12h8M8 15.5h5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round"/></svg>
<div class="intro-who__title">핀테크 기업</div>
<p>결제·자산관리 서비스에 은행 계좌 기능을 탑재</p>
</div>
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><path d="M4 20V8l8-4 8 4v12" stroke="#0049B4" stroke-width="1.8" stroke-linejoin="round"/><path d="M9.5 20v-5h5v5" stroke="#00ACDD" stroke-width="1.8" stroke-linejoin="round"/></svg>
<div class="intro-who__title">법인 · 기업 고객</div>
<p>자체 ERP·그룹웨어에서 자금 업무 자동화</p>
</div>
<div class="intro-who__item">
<svg width="34" height="34" viewBox="0 0 24 24" fill="none"><path d="M9 6 4 12l5 6m6-12 5 6-5 6" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div class="intro-who__title">ERP · 회계 솔루션사</div>
<p>SaaS 제품에 임베디드 뱅킹 기능 제공</p>
</div>
</div>
</section>
<!-- SERVICES -->
<section class="intro-section" aria-labelledby="intro-services-title">
<span class="intro-section__eyebrow">API SERVICES</span>
<h2 class="intro-section__title" id="intro-services-title">제공 서비스</h2>
<p class="intro-section__lead">6개 도메인으로 구성되며, 서비스별로 이용 신청과 심사가 개별 진행됩니다.</p>
<div class="intro-grid3">
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="4" y="9.5" width="14" height="9" rx="2.4" stroke="#0049B4" stroke-width="2"/><path d="M7.5 9.5V7a3.5 3.5 0 1 1 7 0v2.5" stroke="#0049B4" stroke-width="2"/></svg></div>
<h3>인증 · 토큰</h3>
<p>OAuth2 client_credentials로 access_token을 발급하고, 모든 호출에 Bearer 토큰을 사용합니다.</p>
<span class="intro-card__tag">OAuth2</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><rect x="2.5" y="5" width="17" height="12" rx="2.6" stroke="#0049B4" stroke-width="2"/><path d="M2.5 9.5h17" stroke="#0049B4" stroke-width="2"/></svg></div>
<h3>계좌 · 조회</h3>
<p>실명확인, 계좌 개설, 잔액·거래내역 조회 등 기본 뱅킹 조회 기능을 제공합니다.</p>
<span class="intro-card__tag">Account</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M4 7h11l-3-3m6 11H7l3 3" stroke="#0049B4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h3>이체 · 자금</h3>
<p>단건·대량이체, 급여이체, 예약이체와 이체 결과 조회를 지원합니다.</p>
<span class="intro-card__tag">Transfer</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M3 18V9m5 9V4m5 14v-6m5 6V7" stroke="#0049B4" stroke-width="2.2" stroke-linecap="round"/></svg></div>
<h3>기업여신</h3>
<p>사전 한도 조회, 대출 신청·실행·상환, 매출채권 기반 여신 연계를 처리합니다.</p>
<span class="intro-card__tag">Loan</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><circle cx="11" cy="11" r="7.5" stroke="#0049B4" stroke-width="2"/><path d="M11 6.5v5l3 2" stroke="#0049B4" stroke-width="2" stroke-linecap="round"/></svg></div>
<h3>수납 · 외환</h3>
<p>가상계좌 발급·입금 통지, 공과금 수납, 환율 조회 등 부가 금융 서비스입니다.</p>
<span class="intro-card__tag">Billing / FX</span>
</div>
<div class="intro-card">
<div class="intro-card__icon"><svg width="22" height="22" viewBox="0 0 22 22" fill="none"><path d="M11 3a6 6 0 0 1 6 6v4l2 3H4l2-3V9a6 6 0 0 1 5-6Z" stroke="#0049B4" stroke-width="2" stroke-linejoin="round"/><path d="M9 18.5a2.2 2.2 0 0 0 4 0" stroke="#0049B4" stroke-width="2" stroke-linecap="round"/></svg></div>
<h3>웹훅 · 알림</h3>
<p>입출금, 심사 결과, 상태 변경 이벤트를 등록된 URL로 실시간 전송합니다.</p>
<span class="intro-card__tag">Webhook</span>
</div>
</div>
</section>
<!-- ARCHITECTURE -->
<section class="intro-section" aria-labelledby="intro-arch-title">
<span class="intro-section__eyebrow">ARCHITECTURE</span>
<h2 class="intro-section__title" id="intro-arch-title">연동 구조</h2>
<div class="intro-diagram">
<svg width="100%" viewBox="0 0 950 250" fill="none">
<rect x="8" y="48" width="200" height="152" rx="14" fill="#EDF9FE" stroke="#C6DEF5"/>
<text x="108" y="38" text-anchor="middle" font-size="13" font-weight="700" fill="#0049B4">PARTNER</text>
<text x="108" y="106" text-anchor="middle" font-size="16" font-weight="800" fill="#0B2A5B">파트너 시스템</text>
<text x="108" y="134" text-anchor="middle" font-size="12.5" fill="#55688A">핀테크 앱 · ERP · 회계 SaaS</text>
<text x="108" y="154" text-anchor="middle" font-size="12.5" fill="#55688A">법인 자체 시스템</text>
<path d="M214 124h96" stroke="#0049B4" stroke-width="2.4"/><path d="m306 118 10 6-10 6" fill="#0049B4"/>
<text x="262" y="112" text-anchor="middle" font-size="11.5" font-weight="700" fill="#0049B4">HTTPS / OAuth2</text>
<rect x="322" y="26" width="286" height="196" rx="14" fill="#0B2A5B"/>
<text x="465" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="#4E9BE0">JEJU BANK API PORTAL</text>
<rect x="346" y="74" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="405" y="100" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">인증 서버</text>
<rect x="470" y="74" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="529" y="100" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">API Gateway</text>
<rect x="346" y="126" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="405" y="152" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">유량 · IP 제어</text>
<rect x="470" y="126" width="118" height="42" rx="9" fill="rgba(255,255,255,.1)"/><text x="529" y="152" text-anchor="middle" font-size="12.5" font-weight="600" fill="#fff">로그 · 모니터링</text>
<text x="465" y="198" text-anchor="middle" font-size="11.5" fill="#9EB6D6">샌드박스 · 키 관리 · 호출 이력 · 통계</text>
<path d="M614 124h96" stroke="#0049B4" stroke-width="2.4"/><path d="m706 118 10 6-10 6" fill="#0049B4"/>
<text x="662" y="112" text-anchor="middle" font-size="11.5" font-weight="700" fill="#0049B4">내부 전문</text>
<rect x="722" y="48" width="212" height="152" rx="14" fill="#EDF9FE" stroke="#C6DEF5"/>
<text x="828" y="38" text-anchor="middle" font-size="13" font-weight="700" fill="#0049B4">CORE BANKING</text>
<text x="828" y="102" text-anchor="middle" font-size="16" font-weight="800" fill="#0B2A5B">[[${brandName}]] 계정계</text>
<text x="828" y="130" text-anchor="middle" font-size="12.5" fill="#55688A">수신 · 여신 · 외환 원장</text>
<text x="828" y="150" text-anchor="middle" font-size="12.5" fill="#55688A">심사 · 컴플라이언스</text>
</svg>
</div>
</section>
<!-- PROCESS -->
<section class="intro-section" aria-labelledby="intro-process-title">
<span class="intro-section__eyebrow">PROCESS</span>
<h2 class="intro-section__title" id="intro-process-title">이용 절차</h2>
<div class="intro-steps">
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><circle cx="10" cy="8" r="3.4" stroke="#0049B4" stroke-width="1.8"/><path d="M3 20c.6-3.6 3.3-5.4 7-5.4" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round"/><path d="M17 13v7m-3.5-3.5h7" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 01</em>회원가입</div><p>법인 회원으로 온라인 가입을 신청합니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><rect x="4" y="3" width="16" height="18" rx="3" stroke="#0049B4" stroke-width="1.8"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 02</em>승인</div><p>운영 담당자가 신청 계정 정보를 확인한 후 승인합니다. 법인 관리자는 승인 이후 실제 사용할 직원(개발자)을 추가 등록합니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M9.5 4.5 3.5 12l6 7.5" stroke="#F08A24" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="m14.5 4.5 6 7.5-6 7.5" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 03</em>API / APP 사용 신청</div><p>[내 앱]에서 애플리케이션을 등록하고 필요한 API를 선택해 신청하면 ClientID / Secret이 발급됩니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M4 17.5 9 12l3.5 3.5L20 8" stroke="#0049B4" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M15 8h5v5" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 04</em>샌드박스 개발 · 테스트</div><p>테스트 키로 토큰 발급과 API 호출, 웹훅 수신을 검증합니다. 테스트 데이터는 실제 원장에 반영되지 않습니다.</p></div>
</div>
<div class="intro-step">
<div class="intro-step__icon"><svg width="30" height="30" viewBox="0 0 24 24" fill="none"><path d="M12 3 4 6.5v6c0 4.5 3.3 7.6 8 8.8 4.7-1.2 8-4.3 8-8.8v-6L12 3Z" stroke="#0049B4" stroke-width="1.8" stroke-linejoin="round"/><path d="m8.8 12.2 2.4 2.4 4.2-4.8" stroke="#00ACDD" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<div class="intro-step__body"><div class="intro-step__title"><em>STEP 05</em>운영 전환</div><p>보안 점검과 계약 절차를 마치면 운영 키가 발급되고 실거래 호출이 시작됩니다.</p></div>
</div>
</div>
</section>
<!-- SECURITY -->
<section class="intro-section" aria-labelledby="intro-security-title">
<span class="intro-section__eyebrow">SECURITY &amp; OPERATION</span>
<h2 class="intro-section__title" id="intro-security-title">보안 및 운영 정책</h2>
<div class="intro-grid3">
<div class="intro-card">
<h3>인증 · 통신</h3>
<p>OAuth2 client_credentials 방식으로 발급한 토큰을 <code>X-AUTH-TOKEN</code> 헤더로 전달합니다. 모든 구간은 TLS 1.2 이상으로 암호화합니다.</p>
</div>
<div class="intro-card">
<h3>접근 통제</h3>
<p>앱 단위로 IP 허용목록과 호출 유량(rate limit)을 적용하여 운영하고 있습니다.</p>
</div>
<div class="intro-card">
<h3>모니터링 · 지원</h3>
<p>포탈에서 호출 이력·응답코드·지연 통계를 조회할 수 있으며, 장애 상황은 API Status 페이지와 등록 메일로 공지합니다.</p>
</div>
</div>
</section>
<!-- CTA -->
<div class="intro-cta">
<div class="intro-cta__body">
<h3>[[${brandName}]] API, 지금 신청하세요</h3>
<p>가입 후 앱을 등록하면 샌드박스 키가 발급되어 바로 개발을 시작할 수 있습니다.</p>
</div>
<a class="btn-action-primary" th:href="@{/signup}">개발자 회원가입</a>
</div>
</section>
</div>
</div>
</th:block>
</body>
@@ -27,7 +27,7 @@
<rect x="160" y="90" width="80" height="50" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="200" y="112" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">DJBank</text>
fill="#0049b4">[[${brandName}]]</text>
<text x="200" y="126" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Open API</text>
@@ -51,7 +51,7 @@
개발 가이드 · 2-Legged · Client Credentials
</span>
<h1 class="oauth2-2legged__hero-title">OAuth2 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank Open API를 호출하기 위한 ClientID/Secret 기반 토큰
<p class="oauth2-2legged__hero-lead">[[${brandName}]] Open API를 호출하기 위한 ClientID/Secret 기반 토큰
발급과<br>Bearer 인증 호출 방법을 단계별로 설명합니다.</p>
</div>
@@ -129,7 +129,7 @@
<rect x="760" y="24" width="240" height="48" rx="24" fill="#FFFFFF"
stroke="#0049b4" />
<text x="880" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">DJBank Open API</text>
fill="#0049b4">[[${brandName}]] Open API</text>
<line x1="880" y1="72" x2="880" y2="254" stroke="#94A3B8" stroke-dasharray="4 4" />
</g>
@@ -441,7 +441,7 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 DJBank 확장 코드</p>
<p class="oauth2-2legged__error-note">⚠ error 코드는 RFC 6749 표준 코드 또는 [[${brandName}]] 확장 코드</p>
</div>
</div>
</section>
@@ -451,7 +451,7 @@ HTTP/1.1 <span class="o2leg-g">200 OK</span>
<div class="oauth2-2legged__cta-body">
<span class="oauth2-2legged__cta-eyebrow">EXPLORE</span>
<h2 class="oauth2-2legged__cta-title">API 목록 보러가기</h2>
<p class="oauth2-2legged__cta-desc">사용 가능한 DJBank Open API 카탈로그를 확인하세요.</p>
<p class="oauth2-2legged__cta-desc">사용 가능한 [[${brandName}]] Open API 카탈로그를 확인하세요.</p>
<span class="oauth2-2legged__cta-button">API 목록 →</span>
</div>
<span class="oauth2-2legged__cta-deco oauth2-2legged__cta-deco--lg" aria-hidden="true"></span>
@@ -16,7 +16,7 @@
개발 가이드 · Webhook · HMAC-SHA256
</span>
<h1 class="oauth2-2legged__hero-title">웹훅 개발가이드</h1>
<p class="oauth2-2legged__hero-lead">DJBank가 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
<p class="oauth2-2legged__hero-lead">[[${brandName}]][[${brandNameJosaGa}]] 발송하는 Webhook 요청의 진위를 확인하기 위한 HMAC-SHA256 서명 검증 방법을
단계별로 설명합니다.</p>
<!-- <div class="oauth2-2legged__hero-chips">
@@ -39,7 +39,7 @@
<rect x="24" y="92" width="86" height="52" rx="8" fill="#EDF9FE" stroke="#0049b4" />
<text x="67" y="114" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">DJBank</text>
fill="#0049b4">[[${brandName}]]</text>
<text x="67" y="128" text-anchor="middle" font-size="10" font-weight="700"
fill="#0049b4">Webhook</text>
@@ -137,7 +137,7 @@
<rect x="120" y="24" width="240" height="48" rx="24" fill="#EDF9FE"
stroke="#0049b4" />
<text x="240" y="54" text-anchor="middle" font-size="14" font-weight="700"
fill="#0049b4">DJBank Webhook Sender</text>
fill="#0049b4">[[${brandName}]] Webhook Sender</text>
<line x1="240" y1="72" x2="240" y2="272" stroke="#94A3B8" stroke-dasharray="4 4" />
</g>
<g>
@@ -175,7 +175,7 @@
<section class="oauth2-2legged__step" aria-labelledby="whsig-step1-title">
<span class="oauth2-2legged__eyebrow">STEP 1</span>
<h2 class="oauth2-2legged__h2" id="whsig-step1-title">수신 요청 형식</h2>
<p class="oauth2-2legged__desc">DJBank는 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
<p class="oauth2-2legged__desc">[[${brandName}]][[${brandNameJosaEun}]] 등록한 수신 URL로 아래 형태의 POST 요청을 전송합니다.</p>
<div class="oauth2-2legged__endpoint-box">
<span class="oauth2-2legged__method">POST</span>
@@ -210,10 +210,17 @@
<td><code>Content-Type</code></td>
<td>application/json</td>
</tr>
<tr>
<td><code>X-Webhook-Secret</code></td>
<td>Webhook 관리 화면에서 직접 설정한 값 — <strong>설정한 경우에만</strong> 전송(선택)</td>
</tr>
</tbody>
</table>
<p class="oauth2-2legged__warning">⚠ 서명 대상은 파싱 전 <strong>본문 원문(raw body)</strong> 입니다.
</p>
<p class="oauth2-2legged__warning"><code>X-Webhook-Secret</code>은 서명 검증과 무관합니다.
Webhook 신청/수정 시 입력한 값을 그대로 echo 하는 헤더로, 수신측에서 추가로 값을 대조하고 싶을 때만
사용하세요(값을 설정하지 않았다면 이 헤더는 아예 오지 않습니다).</p>
</div>
<div class="oauth2-2legged__code-panel">
@@ -222,10 +229,11 @@
<span class="o2leg-c">"eventType"</span>: <span class="o2leg-y">"CHECK_START"</span>,
<span class="o2leg-c">"eventId"</span>: <span class="o2leg-y">"f47ac10b-58cc-4372-a567-0e02b2c3d479"</span>,
<span class="o2leg-c">"timestamp"</span>: <span class="o2leg-p">1723600000000</span>,
<span class="o2leg-c">"message"</span>: <span class="o2leg-y">"DB 점검으로 15분간 서비스가 중단됩니다."</span>,
<span class="o2leg-c">"data"</span>: [ <span class="o2leg-y">"TESTCASE003S1"</span>, <span class="o2leg-y">"TESTCASE005S1"</span> ]
}
<span class="o2leg-g"># eventId: 발송 건 고유 ID · data: 영향 API 목록</span></pre>
<span class="o2leg-g"># eventId: 발송 건 고유 ID · message: 관리자가 입력한 안내 문구(자유텍스트, 생략될 수 있음) · data: 영향 API 목록</span></pre>
</div>
</div>
</section>
@@ -374,7 +382,7 @@ valid = constantTimeEquals(received, expected)</pre>
<section class="oauth2-2legged__step" aria-labelledby="whsig-step4-title">
<span class="oauth2-2legged__eyebrow">STEP 4</span>
<h2 class="oauth2-2legged__h2" id="whsig-step4-title">응답(리턴) 반환 규칙</h2>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 DJBank의 성공 판정과 재시도가 결정됩니다.</p>
<p class="oauth2-2legged__desc">수신 서버가 반환하는 HTTP 상태 코드에 따라 [[${brandName}]]의 성공 판정과 재시도가 결정됩니다.</p>
<div class="oauth2-2legged__step-grid">
<div class="oauth2-2legged__panel">
@@ -384,7 +392,7 @@ valid = constantTimeEquals(received, expected)</pre>
<tr>
<th>반환</th>
<th>상황</th>
<th>DJBank 처리</th>
<th>[[${brandName}]] 처리</th>
</tr>
</thead>
<tbody>
@@ -76,9 +76,9 @@
</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">