Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ba00b80bfe | |||
| bdb249b74b | |||
| 2c29c8a466 | |||
| d2052d0e16 | |||
| 9c4f3cfb04 |
@@ -0,0 +1,370 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.controller;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.apps.apiservice.dto.ApiGroupSearch;
|
||||||
|
import com.eactive.apim.portal.apps.app.service.AppServiceFacade;
|
||||||
|
import com.eactive.apim.portal.apps.apiservice.service.ApiServiceService;
|
||||||
|
import com.eactive.apim.portal.common.user.PortalAuthenticatedUser;
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
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.WebhookRegistrationDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.service.WebhookEventTypeProvider;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.service.WebhookService;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import javax.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.access.annotation.Secured;
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.ui.Model;
|
||||||
|
import org.springframework.validation.BindingResult;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
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.ResponseBody;
|
||||||
|
import org.springframework.web.bind.annotation.SessionAttributes;
|
||||||
|
import org.springframework.web.bind.support.SessionStatus;
|
||||||
|
import org.springframework.web.servlet.ModelAndView;
|
||||||
|
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청/관리 (기업 사용자, ROLE_APP).
|
||||||
|
*
|
||||||
|
* App API Key 신청의 3-Step + 세션 + 비밀번호 재인증 패턴을 답습하되, 승인 워크플로우 없이 즉시 발급한다.
|
||||||
|
* 참조: {@code apps/app/controller/MyAppController}.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/webhook")
|
||||||
|
@Secured("ROLE_APP")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@SessionAttributes({"webhookRegistration", "webhookModification"})
|
||||||
|
public class WebhookController {
|
||||||
|
|
||||||
|
private static final int TOTAL_STEPS = 3;
|
||||||
|
|
||||||
|
private final WebhookService webhookService;
|
||||||
|
private final WebhookEventTypeProvider eventTypeProvider;
|
||||||
|
private final ApiServiceService apiServiceService;
|
||||||
|
private final AppServiceFacade appServiceFacade;
|
||||||
|
|
||||||
|
@ModelAttribute("webhookRegistration")
|
||||||
|
public WebhookRegistrationDTO webhookRegistration() {
|
||||||
|
return new WebhookRegistrationDTO();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModelAttribute("webhookModification")
|
||||||
|
public WebhookRegistrationDTO webhookModification() {
|
||||||
|
return new WebhookRegistrationDTO();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ 조회 ============================
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ModelAndView index() {
|
||||||
|
String orgId = currentOrgId();
|
||||||
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(orgId);
|
||||||
|
if (webhook.isPresent()) {
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookList");
|
||||||
|
mav.addObject("webhook", webhook.get());
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
return new ModelAndView("apps/webhook/webhookEmpty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================= 신규 신청 플로우 =======================
|
||||||
|
|
||||||
|
@GetMapping("/register/step1")
|
||||||
|
public ModelAndView registerStep1(
|
||||||
|
@RequestParam(value = "clear", required = false, defaultValue = "false") boolean clear,
|
||||||
|
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||||
|
SessionStatus sessionStatus,
|
||||||
|
Model model) {
|
||||||
|
|
||||||
|
if (webhookService.existsByOrg(currentOrgId())) {
|
||||||
|
return new ModelAndView("redirect:/webhook");
|
||||||
|
}
|
||||||
|
if (clear) {
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
registration = new WebhookRegistrationDTO();
|
||||||
|
model.addAttribute("webhookRegistration", registration);
|
||||||
|
}
|
||||||
|
registration.setRequestType("NEW");
|
||||||
|
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||||
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
addStepModel(mav, 1);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register/step1")
|
||||||
|
public ModelAndView processStep1(
|
||||||
|
@Valid @ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||||
|
BindingResult bindingResult,
|
||||||
|
Model model) {
|
||||||
|
|
||||||
|
validateStep1(registration, bindingResult);
|
||||||
|
if (bindingResult.hasErrors()) {
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep1");
|
||||||
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
addStepModel(mav, 1);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/register/step2")
|
||||||
|
public ModelAndView registerStep2(
|
||||||
|
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||||
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
|
if (!registration.isStep1Complete()) {
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step1");
|
||||||
|
}
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep2");
|
||||||
|
mav.addObject("apiGroups", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||||
|
mav.addObject("selectedApis", registration.getSelectedApis());
|
||||||
|
addStepModel(mav, 2);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/register/step2")
|
||||||
|
public ModelAndView processStep2(
|
||||||
|
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||||
|
@ModelAttribute("webhookRegistration") WebhookRegistrationDTO registration,
|
||||||
|
SessionStatus sessionStatus,
|
||||||
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
|
registration.setSelectedApis(selectedApis);
|
||||||
|
if (!registration.isStep2Complete()) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step2");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
WebhookCreatedResult result = webhookService.create(registration, currentOrgId());
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
redirectAttributes.addFlashAttribute("registrationSuccess", true);
|
||||||
|
redirectAttributes.addFlashAttribute("secret", result.getSecret());
|
||||||
|
redirectAttributes.addFlashAttribute("webhookId", result.getId());
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step3");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Webhook 신청 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||||
|
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/register/step3")
|
||||||
|
public ModelAndView registerStep3(Model model) {
|
||||||
|
if (!Boolean.TRUE.equals(model.getAttribute("registrationSuccess"))) {
|
||||||
|
return new ModelAndView("redirect:/webhook");
|
||||||
|
}
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookRegisterStep3");
|
||||||
|
addStepModel(mav, 3);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/register/cancel")
|
||||||
|
public String cancelRegistration(SessionStatus sessionStatus) {
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
return "redirect:/webhook";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================= 수정 플로우 =========================
|
||||||
|
|
||||||
|
@GetMapping("/modify/step1")
|
||||||
|
public ModelAndView modifyStep1(
|
||||||
|
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||||
|
Model model) {
|
||||||
|
|
||||||
|
Optional<WebhookDTO> current = webhookService.getByOrg(currentOrgId());
|
||||||
|
if (!current.isPresent()) {
|
||||||
|
return new ModelAndView("redirect:/webhook/register/step1?clear=true");
|
||||||
|
}
|
||||||
|
WebhookDTO webhook = current.get();
|
||||||
|
// 세션에 아직 채워지지 않았으면 현재 등록값으로 초기화
|
||||||
|
if (modification.getId() == null || !webhook.getId().equals(modification.getId())) {
|
||||||
|
modification.setId(webhook.getId());
|
||||||
|
modification.setRequestType("MODIFY");
|
||||||
|
modification.setTargetUrl(webhook.getTargetUrl());
|
||||||
|
modification.setEventTypes(webhook.getEventTypes().stream()
|
||||||
|
.map(e -> e.getCode()).collect(java.util.stream.Collectors.toList()));
|
||||||
|
modification.setSelectedApis(new java.util.ArrayList<>(webhook.getApiIds()));
|
||||||
|
model.addAttribute("webhookModification", modification);
|
||||||
|
}
|
||||||
|
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||||
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
addStepModel(mav, 1);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/modify/step1")
|
||||||
|
public ModelAndView processModifyStep1(
|
||||||
|
@Valid @ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||||
|
BindingResult bindingResult,
|
||||||
|
Model model) {
|
||||||
|
|
||||||
|
validateStep1(modification, bindingResult);
|
||||||
|
if (bindingResult.hasErrors()) {
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep1");
|
||||||
|
mav.addObject("eventTypes", eventTypeProvider.getAll());
|
||||||
|
addStepModel(mav, 1);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/modify/step2")
|
||||||
|
public ModelAndView modifyStep2(
|
||||||
|
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification) {
|
||||||
|
|
||||||
|
if (modification.getId() == null || !modification.isStep1Complete()) {
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step1");
|
||||||
|
}
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep2");
|
||||||
|
mav.addObject("apiGroups", apiServiceService.searchApiGroups(new ApiGroupSearch()));
|
||||||
|
mav.addObject("selectedApis", modification.getSelectedApis());
|
||||||
|
addStepModel(mav, 2);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/modify/step2")
|
||||||
|
public ModelAndView processModifyStep2(
|
||||||
|
@RequestParam(value = "selectedApis", required = false) List<String> selectedApis,
|
||||||
|
@ModelAttribute("webhookModification") WebhookRegistrationDTO modification,
|
||||||
|
SessionStatus sessionStatus,
|
||||||
|
RedirectAttributes redirectAttributes) {
|
||||||
|
|
||||||
|
modification.setSelectedApis(selectedApis);
|
||||||
|
if (modification.getId() == null || !modification.isStep2Complete()) {
|
||||||
|
redirectAttributes.addFlashAttribute("error", "알림 대상 API를 1개 이상 선택해주세요.");
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
webhookService.update(modification.getId(), modification, currentOrgId());
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
redirectAttributes.addFlashAttribute("modifySuccess", true);
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step3");
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Webhook 수정 실패 orgId={} : {}", currentOrgId(), e.getMessage());
|
||||||
|
redirectAttributes.addFlashAttribute("error", e.getMessage());
|
||||||
|
return new ModelAndView("redirect:/webhook/modify/step2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/modify/step3")
|
||||||
|
public ModelAndView modifyStep3(Model model) {
|
||||||
|
if (!Boolean.TRUE.equals(model.getAttribute("modifySuccess"))) {
|
||||||
|
return new ModelAndView("redirect:/webhook");
|
||||||
|
}
|
||||||
|
ModelAndView mav = new ModelAndView("apps/webhook/webhookModifyStep3");
|
||||||
|
addStepModel(mav, 3);
|
||||||
|
return mav;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/modify/cancel")
|
||||||
|
public String cancelModification(SessionStatus sessionStatus) {
|
||||||
|
sessionStatus.setComplete();
|
||||||
|
return "redirect:/webhook";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AJAX (비밀번호 재인증) ====================
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
|
if (!webhook.isPresent()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "등록된 Webhook이 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("secret", webhookService.getPlainSecret(webhook.get().getId(), 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;
|
||||||
|
}
|
||||||
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
|
if (!webhook.isPresent()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "등록된 Webhook이 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
String secret = webhookService.regenerateSecret(webhook.get().getId(), currentOrgId());
|
||||||
|
result.put("success", true);
|
||||||
|
result.put("secret", secret);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
Optional<WebhookDTO> webhook = webhookService.getByOrg(currentOrgId());
|
||||||
|
if (!webhook.isPresent()) {
|
||||||
|
result.put("success", false);
|
||||||
|
result.put("message", "등록된 Webhook이 없습니다.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
webhookService.delete(webhook.get().getId(), currentOrgId());
|
||||||
|
result.put("success", true);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================ helper ============================
|
||||||
|
|
||||||
|
private void validateStep1(WebhookRegistrationDTO dto, BindingResult bindingResult) {
|
||||||
|
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||||
|
if (!bindingResult.hasFieldErrors("targetUrl")
|
||||||
|
&& !url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
bindingResult.rejectValue("targetUrl", "invalid.url",
|
||||||
|
"URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||||
|
}
|
||||||
|
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||||
|
bindingResult.rejectValue("eventTypes", "empty.eventTypes",
|
||||||
|
"EventType을 1개 이상 선택해주세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean verifyPassword(String password) {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
return appServiceFacade.verifyUserPassword(user, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String currentOrgId() {
|
||||||
|
PortalAuthenticatedUser user = SecurityUtil.getPortalAuthenticatedUser();
|
||||||
|
return user != null && user.getPortalOrg() != null ? user.getPortalOrg().getId() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addStepModel(ModelAndView mav, int currentStep) {
|
||||||
|
mav.addObject("currentStep", currentStep);
|
||||||
|
mav.addObject("totalSteps", TOTAL_STEPS);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 신청 결과. 평문 {@code secret} 은 발급 직후 1회 노출 목적으로만 전달된다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebhookCreatedResult {
|
||||||
|
|
||||||
|
private final Long id;
|
||||||
|
private final String secret;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 등록된 Webhook 상세 표시용. SECRET 은 마스킹 값만 담고 평문은 별도 재인증 조회로만 노출한다.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class WebhookDTO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String targetUrl;
|
||||||
|
private String secretMasked;
|
||||||
|
private String createdDate;
|
||||||
|
|
||||||
|
/** 구독 API ID 목록. */
|
||||||
|
private List<String> apiIds = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 구독 EventType(코드+한글명) 목록. */
|
||||||
|
private List<WebhookEventTypeDTO> eventTypes = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventType 코드/한글명 쌍. TSEAIRM28(CODEGROUP='EVENT_TYPE') 에서 로드.
|
||||||
|
* {@code selected} 는 신청 화면에서 현재 구독 여부 표시용.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class WebhookEventTypeDTO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String code;
|
||||||
|
private String name;
|
||||||
|
private boolean selected;
|
||||||
|
|
||||||
|
public WebhookEventTypeDTO(String code, String name) {
|
||||||
|
this.code = code;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.dto;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.Data;
|
||||||
|
import org.hibernate.validator.constraints.Length;
|
||||||
|
import org.hibernate.validator.constraints.NotBlank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청/수정 스텝 간 세션 보존 데이터 (App 신청 {@code ApiKeyRegistrationDTO} 답습).
|
||||||
|
*
|
||||||
|
* Step1: {@code targetUrl} + {@code eventTypes}. Step2: {@code selectedApis}.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class WebhookRegistrationDTO implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 수정 시 대상 신청 ID. 신규 신청이면 null. */
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** "NEW" 또는 "MODIFY" */
|
||||||
|
private String requestType;
|
||||||
|
|
||||||
|
@NotBlank(message = "Webhook 수신 URL을 입력해주세요.")
|
||||||
|
@Length(max = 255, message = "URL은 255자를 초과할 수 없습니다.")
|
||||||
|
private String targetUrl;
|
||||||
|
|
||||||
|
/** Step1: 구독 EventType 코드 목록 (TSEAIRM28 EVENT_TYPE). */
|
||||||
|
private List<String> eventTypes = new ArrayList<>();
|
||||||
|
|
||||||
|
/** Step2: 알림 대상 API ID 목록. */
|
||||||
|
private List<String> selectedApis = new ArrayList<>();
|
||||||
|
|
||||||
|
public boolean isStep1Complete() {
|
||||||
|
return targetUrl != null && !targetUrl.trim().isEmpty()
|
||||||
|
&& eventTypes != null && !eventTypes.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isStep2Complete() {
|
||||||
|
return selectedApis != null && !selectedApis.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isComplete() {
|
||||||
|
return isStep1Complete() && isStep2Complete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org 당 Webhook 1건 정책 위반(이미 등록됨).
|
||||||
|
*/
|
||||||
|
public class WebhookAlreadyExistsException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public WebhookAlreadyExistsException(String orgId) {
|
||||||
|
super("이미 등록된 Webhook이 있습니다. orgId=" + orgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대상 Webhook 신청을 찾을 수 없음.
|
||||||
|
*/
|
||||||
|
public class WebhookNotFoundException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
public WebhookNotFoundException(Long id) {
|
||||||
|
super("Webhook 신청을 찾을 수 없습니다. id=" + id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.mapper;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||||
|
import org.mapstruct.Mapper;
|
||||||
|
import org.mapstruct.Mapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebhookRequest → WebhookDTO 기본 필드 매핑.
|
||||||
|
* secretMasked/apiIds/eventTypes 는 연관 테이블 조립이 필요하므로 서비스에서 채운다.
|
||||||
|
*/
|
||||||
|
@Mapper(componentModel = "spring")
|
||||||
|
public interface WebhookMapper {
|
||||||
|
|
||||||
|
@Mapping(target = "secretMasked", ignore = true)
|
||||||
|
@Mapping(target = "apiIds", ignore = true)
|
||||||
|
@Mapping(target = "eventTypes", ignore = true)
|
||||||
|
WebhookDTO toDto(WebhookRequest entity);
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.MonitoringCode;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.MonitoringCodeId;
|
||||||
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TSEAIRM28 모니터링 공통코드 조회 — Webhook EventType 목록(CODEGROUP='EVENT_TYPE') 전용.
|
||||||
|
*/
|
||||||
|
@EMSDataSource
|
||||||
|
public interface MonitoringCodeRepository extends JpaRepository<MonitoringCode, MonitoringCodeId> {
|
||||||
|
|
||||||
|
List<MonitoringCode> findByCodeGroupAndUseYnOrderBySeqAscCodeAsc(String codeGroup, String useYn);
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApi;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestApiId;
|
||||||
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PTL_WEBHOOK_REQ_API — 신청별 구독 API 목록.
|
||||||
|
*/
|
||||||
|
@EMSDataSource
|
||||||
|
public interface WebhookRequestApiRepository extends JpaRepository<WebhookRequestApi, WebhookRequestApiId> {
|
||||||
|
|
||||||
|
List<WebhookRequestApi> findByWebhookReqId(Long webhookReqId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByWebhookReqId(Long webhookReqId);
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEventId;
|
||||||
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PTL_WEBHOOK_REQ_EVENT — 신청별 구독 EventType 목록.
|
||||||
|
*/
|
||||||
|
@EMSDataSource
|
||||||
|
public interface WebhookRequestEventRepository extends JpaRepository<WebhookRequestEvent, WebhookRequestEventId> {
|
||||||
|
|
||||||
|
List<WebhookRequestEvent> findByWebhookReqId(Long webhookReqId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByWebhookReqId(Long webhookReqId);
|
||||||
|
}
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequest;
|
||||||
|
import com.eactive.eai.rms.data.EMSDataSource;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PTL_WEBHOOK_REQ 조회/저장. Org 당 1건 정책이라 orgId 단건 조회를 제공한다.
|
||||||
|
*/
|
||||||
|
@EMSDataSource
|
||||||
|
public interface WebhookRequestRepository extends JpaRepository<WebhookRequest, Long> {
|
||||||
|
|
||||||
|
Optional<WebhookRequest> findByOrgId(String orgId);
|
||||||
|
|
||||||
|
boolean existsByOrgId(String orgId);
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모니터링 공통코드 (EMSAPP.TSEAIRM28) — 읽기 전용.
|
||||||
|
*
|
||||||
|
* Webhook EventType 목록은 CODEGROUP='EVENT_TYPE' 행에서 조회한다(admin 발송엔진과 단일 소스).
|
||||||
|
* 코드/한글명 매핑만 필요하므로 최소 컬럼만 매핑한다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "TSEAIRM28")
|
||||||
|
@IdClass(MonitoringCodeId.class)
|
||||||
|
public class MonitoringCode implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CODEGROUP", length = 50)
|
||||||
|
private String codeGroup;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "CODE", length = 50)
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
@Column(name = "CODENAME", length = 150)
|
||||||
|
private String codeName;
|
||||||
|
|
||||||
|
@Column(name = "SEQ")
|
||||||
|
private Long seq;
|
||||||
|
|
||||||
|
@Column(name = "USEYN", length = 1)
|
||||||
|
private String useYn;
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link MonitoringCode} 복합키 (CODEGROUP + CODE).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode
|
||||||
|
public class MonitoringCodeId implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private String codeGroup;
|
||||||
|
private String code;
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.GeneratedValue;
|
||||||
|
import javax.persistence.GenerationType;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.PrePersist;
|
||||||
|
import javax.persistence.SequenceGenerator;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청 마스터 (EMSAPP.PTL_WEBHOOK_REQ).
|
||||||
|
*
|
||||||
|
* admin(eapim-admin) 발송엔진이 이 행을 읽어 TARGET_URL 로 HMAC-SHA256 서명 발송한다.
|
||||||
|
* SECRET 은 서명 키로 그대로 사용되므로 암호화하지 않고 평문 저장한다.
|
||||||
|
* CREATED_DATE 는 VARCHAR2(14) yyyyMMddHHmmss 문자열이라 AbstractAuditingEntity 를 쓰지 않고
|
||||||
|
* {@link #onCreate()} 에서 직접 세팅한다.
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "PTL_WEBHOOK_REQ")
|
||||||
|
public class WebhookRequest implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "webhookReqSeq")
|
||||||
|
@SequenceGenerator(name = "webhookReqSeq", sequenceName = "SEQ_PTL_WEBHOOK_REQ_ID", allocationSize = 1)
|
||||||
|
@Column(name = "ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "ORG_ID", length = 36)
|
||||||
|
private String orgId;
|
||||||
|
|
||||||
|
@Column(name = "TARGET_URL", length = 255)
|
||||||
|
private String targetUrl;
|
||||||
|
|
||||||
|
@Column(name = "SECRET", length = 500)
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_BY", length = 200)
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_DATE", length = 14)
|
||||||
|
private String createdDate;
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void onCreate() {
|
||||||
|
if (createdBy == null) {
|
||||||
|
createdBy = SecurityUtil.getCurrentLoginId();
|
||||||
|
}
|
||||||
|
if (createdDate == null) {
|
||||||
|
createdDate = LocalDateTime.now().format(TS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.PrePersist;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청이 알림을 받을 API 목록 (EMSAPP.PTL_WEBHOOK_REQ_API).
|
||||||
|
* 복합키(WEBHOOK_REQ_ID + API_ID).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "PTL_WEBHOOK_REQ_API")
|
||||||
|
@IdClass(WebhookRequestApiId.class)
|
||||||
|
public class WebhookRequestApi implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "WEBHOOK_REQ_ID")
|
||||||
|
private Long webhookReqId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "API_ID", length = 30)
|
||||||
|
private String apiId;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_BY", length = 200)
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_DATE", length = 14)
|
||||||
|
private String createdDate;
|
||||||
|
|
||||||
|
public WebhookRequestApi() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebhookRequestApi(Long webhookReqId, String apiId) {
|
||||||
|
this.webhookReqId = webhookReqId;
|
||||||
|
this.apiId = apiId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void onCreate() {
|
||||||
|
if (createdBy == null) {
|
||||||
|
createdBy = SecurityUtil.getCurrentLoginId();
|
||||||
|
}
|
||||||
|
if (createdDate == null) {
|
||||||
|
createdDate = LocalDateTime.now().format(TS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WebhookRequestApi} 복합키 (WEBHOOK_REQ_ID + API_ID).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode
|
||||||
|
public class WebhookRequestApiId implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long webhookReqId;
|
||||||
|
private String apiId;
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.common.util.SecurityUtil;
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import javax.persistence.Column;
|
||||||
|
import javax.persistence.Entity;
|
||||||
|
import javax.persistence.Id;
|
||||||
|
import javax.persistence.IdClass;
|
||||||
|
import javax.persistence.PrePersist;
|
||||||
|
import javax.persistence.Table;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청이 구독하는 EventType 목록 (EMSAPP.PTL_WEBHOOK_REQ_EVENT).
|
||||||
|
* EVENT_TYPE 코드값은 EMSAPP.TSEAIRM28(CODEGROUP='EVENT_TYPE') 과 동일 집합.
|
||||||
|
* 복합키(WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Entity
|
||||||
|
@Table(name = "PTL_WEBHOOK_REQ_EVENT")
|
||||||
|
@IdClass(WebhookRequestEventId.class)
|
||||||
|
public class WebhookRequestEvent implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "WEBHOOK_REQ_ID")
|
||||||
|
private Long webhookReqId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "EVENT_TYPE", length = 36)
|
||||||
|
private String eventType;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_BY", length = 200)
|
||||||
|
private String createdBy;
|
||||||
|
|
||||||
|
@Column(name = "CREATED_DATE", length = 14)
|
||||||
|
private String createdDate;
|
||||||
|
|
||||||
|
public WebhookRequestEvent() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebhookRequestEvent(Long webhookReqId, String eventType) {
|
||||||
|
this.webhookReqId = webhookReqId;
|
||||||
|
this.eventType = eventType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PrePersist
|
||||||
|
public void onCreate() {
|
||||||
|
if (createdBy == null) {
|
||||||
|
createdBy = SecurityUtil.getCurrentLoginId();
|
||||||
|
}
|
||||||
|
if (createdDate == null) {
|
||||||
|
createdDate = LocalDateTime.now().format(TS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.repository.entity;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link WebhookRequestEvent} 복합키 (WEBHOOK_REQ_ID + EVENT_TYPE).
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode
|
||||||
|
public class WebhookRequestEventId implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private Long webhookReqId;
|
||||||
|
private String eventType;
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.service;
|
||||||
|
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookEventTypeDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.MonitoringCodeRepository;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EventType 코드/한글명 제공자. TSEAIRM28(CODEGROUP='EVENT_TYPE', USEYN='Y') 를 조회한다.
|
||||||
|
* admin 발송엔진과 동일 공통코드 테이블을 단일 소스로 사용하므로 코드 추가/변경 시 DB 만 수정하면 된다.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebhookEventTypeProvider {
|
||||||
|
|
||||||
|
private static final String CODE_GROUP = "EVENT_TYPE";
|
||||||
|
private static final String USE_Y = "Y";
|
||||||
|
|
||||||
|
private final MonitoringCodeRepository monitoringCodeRepository;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<WebhookEventTypeDTO> getAll() {
|
||||||
|
return monitoringCodeRepository
|
||||||
|
.findByCodeGroupAndUseYnOrderBySeqAscCodeAsc(CODE_GROUP, USE_Y)
|
||||||
|
.stream()
|
||||||
|
.map(c -> new WebhookEventTypeDTO(c.getCode(), c.getCodeName()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Map<String, String> asMap() {
|
||||||
|
Map<String, String> map = new LinkedHashMap<>();
|
||||||
|
monitoringCodeRepository
|
||||||
|
.findByCodeGroupAndUseYnOrderBySeqAscCodeAsc(CODE_GROUP, USE_Y)
|
||||||
|
.forEach(c -> map.put(c.getCode(), c.getCodeName()));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isValid(String code) {
|
||||||
|
if (code == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return monitoringCodeRepository
|
||||||
|
.findByCodeGroupAndUseYnOrderBySeqAscCodeAsc(CODE_GROUP, USE_Y)
|
||||||
|
.stream()
|
||||||
|
.anyMatch(c -> code.equals(c.getCode()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getName(String code) {
|
||||||
|
return asMap().getOrDefault(code, code);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.service;
|
||||||
|
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook HMAC Secret 생성기.
|
||||||
|
*
|
||||||
|
* admin 발송엔진이 이 값을 그대로 HMAC-SHA256 키로 사용하므로(암복호화 없음),
|
||||||
|
* 128자 영숫자 랜덤 문자열을 평문으로 발급한다(admin 기존 데이터와 동일 형태).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class WebhookSecretGenerator {
|
||||||
|
|
||||||
|
private static final char[] ALPHANUM =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
|
||||||
|
private static final int LENGTH = 128;
|
||||||
|
|
||||||
|
private final SecureRandom random = new SecureRandom();
|
||||||
|
|
||||||
|
public String generate() {
|
||||||
|
StringBuilder sb = new StringBuilder(LENGTH);
|
||||||
|
for (int i = 0; i < LENGTH; i++) {
|
||||||
|
sb.append(ALPHANUM[random.nextInt(ALPHANUM.length)]);
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package com.eactive.apim.portal.djb.webhook.service;
|
||||||
|
|
||||||
|
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;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.dto.WebhookRegistrationDTO;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.exception.WebhookAlreadyExistsException;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.exception.WebhookNotFoundException;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.mapper.WebhookMapper;
|
||||||
|
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.repository.entity.WebhookRequestApi;
|
||||||
|
import com.eactive.apim.portal.djb.webhook.repository.entity.WebhookRequestEvent;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook 신청/조회/수정/삭제 및 Secret 발급 오케스트레이션.
|
||||||
|
*
|
||||||
|
* 단일 EMS 데이터소스만 사용하므로 기본 {@code @Transactional} 로 충분하다(JTA 분산 트랜잭션 불필요).
|
||||||
|
* 모든 수정/삭제/Secret 조회는 소유 Org 검증({@link #loadOwned})을 거친다.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebhookService {
|
||||||
|
|
||||||
|
private static final String SECRET_MASK = "••••••••••••";
|
||||||
|
|
||||||
|
private final WebhookRequestRepository requestRepository;
|
||||||
|
private final WebhookRequestApiRepository apiRepository;
|
||||||
|
private final WebhookRequestEventRepository eventRepository;
|
||||||
|
private final WebhookSecretGenerator secretGenerator;
|
||||||
|
private final WebhookEventTypeProvider eventTypeProvider;
|
||||||
|
private final WebhookMapper webhookMapper;
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean existsByOrg(String orgId) {
|
||||||
|
return requestRepository.existsByOrgId(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<WebhookDTO> getByOrg(String orgId) {
|
||||||
|
return requestRepository.findByOrgId(orgId).map(this::toDetailDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 신규 신청. Org 당 1건 정책 위반 시 예외. 평문 Secret 을 1회 반환한다.
|
||||||
|
*/
|
||||||
|
public WebhookCreatedResult create(WebhookRegistrationDTO dto, String orgId) {
|
||||||
|
if (requestRepository.existsByOrgId(orgId)) {
|
||||||
|
throw new WebhookAlreadyExistsException(orgId);
|
||||||
|
}
|
||||||
|
validate(dto);
|
||||||
|
|
||||||
|
String secret = secretGenerator.generate();
|
||||||
|
WebhookRequest request = new WebhookRequest();
|
||||||
|
request.setOrgId(orgId);
|
||||||
|
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||||
|
request.setSecret(secret);
|
||||||
|
WebhookRequest saved = requestRepository.save(request);
|
||||||
|
|
||||||
|
persistChildren(saved.getId(), dto);
|
||||||
|
log.info("Webhook 신규 등록 orgId={} id={}", orgId, saved.getId());
|
||||||
|
return new WebhookCreatedResult(saved.getId(), secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL/API/EventType 수정. Secret 은 보존한다. 연관 테이블은 delete-all 후 재삽입.
|
||||||
|
*/
|
||||||
|
public WebhookDTO update(Long id, WebhookRegistrationDTO dto, String orgId) {
|
||||||
|
WebhookRequest request = loadOwned(id, orgId);
|
||||||
|
validate(dto);
|
||||||
|
|
||||||
|
request.setTargetUrl(dto.getTargetUrl().trim());
|
||||||
|
requestRepository.save(request);
|
||||||
|
|
||||||
|
apiRepository.deleteByWebhookReqId(id);
|
||||||
|
eventRepository.deleteByWebhookReqId(id);
|
||||||
|
apiRepository.flush();
|
||||||
|
eventRepository.flush();
|
||||||
|
persistChildren(id, dto);
|
||||||
|
|
||||||
|
log.info("Webhook 수정 orgId={} id={}", orgId, id);
|
||||||
|
return toDetailDto(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secret 재발급(교체). 새 평문 Secret 반환.
|
||||||
|
*/
|
||||||
|
public String regenerateSecret(Long id, String orgId) {
|
||||||
|
WebhookRequest request = loadOwned(id, orgId);
|
||||||
|
String secret = secretGenerator.generate();
|
||||||
|
request.setSecret(secret);
|
||||||
|
requestRepository.save(request);
|
||||||
|
log.info("Webhook Secret 재발급 orgId={} id={}", orgId, id);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3개 테이블 HardDelete.
|
||||||
|
*/
|
||||||
|
public void delete(Long id, String orgId) {
|
||||||
|
WebhookRequest request = loadOwned(id, orgId);
|
||||||
|
apiRepository.deleteByWebhookReqId(id);
|
||||||
|
eventRepository.deleteByWebhookReqId(id);
|
||||||
|
requestRepository.delete(request);
|
||||||
|
log.info("Webhook 삭제 orgId={} id={}", orgId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 평문 Secret 조회. 컨트롤러에서 비밀번호 재인증 후에만 호출한다.
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getPlainSecret(Long id, String orgId) {
|
||||||
|
return loadOwned(id, orgId).getSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
private WebhookRequest loadOwned(Long id, String orgId) {
|
||||||
|
WebhookRequest request = requestRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new WebhookNotFoundException(id));
|
||||||
|
if (!Objects.equals(request.getOrgId(), orgId)) {
|
||||||
|
throw new AccessDeniedException("해당 Webhook에 대한 권한이 없습니다.");
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void persistChildren(Long reqId, WebhookRegistrationDTO dto) {
|
||||||
|
for (String apiId : dedup(dto.getSelectedApis())) {
|
||||||
|
apiRepository.save(new WebhookRequestApi(reqId, apiId));
|
||||||
|
}
|
||||||
|
for (String eventType : dedup(dto.getEventTypes())) {
|
||||||
|
if (!eventTypeProvider.isValid(eventType)) {
|
||||||
|
throw new IllegalArgumentException("유효하지 않은 EventType 코드입니다: " + eventType);
|
||||||
|
}
|
||||||
|
eventRepository.save(new WebhookRequestEvent(reqId, eventType));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(WebhookRegistrationDTO dto) {
|
||||||
|
String url = dto.getTargetUrl() == null ? "" : dto.getTargetUrl().trim();
|
||||||
|
if (!url.startsWith("http://") && !url.startsWith("https://")) {
|
||||||
|
throw new IllegalArgumentException("URL은 http:// 또는 https:// 로 시작해야 합니다.");
|
||||||
|
}
|
||||||
|
if (dto.getEventTypes() == null || dto.getEventTypes().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("EventType을 1개 이상 선택해주세요.");
|
||||||
|
}
|
||||||
|
if (dto.getSelectedApis() == null || dto.getSelectedApis().isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("알림 대상 API를 1개 이상 선택해주세요.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> dedup(List<String> values) {
|
||||||
|
if (values == null) {
|
||||||
|
return java.util.Collections.emptyList();
|
||||||
|
}
|
||||||
|
return new java.util.ArrayList<>(new LinkedHashSet<>(values));
|
||||||
|
}
|
||||||
|
|
||||||
|
private WebhookDTO toDetailDto(WebhookRequest request) {
|
||||||
|
WebhookDTO dto = webhookMapper.toDto(request);
|
||||||
|
dto.setSecretMasked(request.getSecret() == null ? "" : SECRET_MASK);
|
||||||
|
|
||||||
|
dto.setApiIds(apiRepository.findByWebhookReqId(request.getId()).stream()
|
||||||
|
.map(WebhookRequestApi::getApiId)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
|
||||||
|
Map<String, String> names = eventTypeProvider.asMap();
|
||||||
|
dto.setEventTypes(eventRepository.findByWebhookReqId(request.getId()).stream()
|
||||||
|
.map(e -> new WebhookEventTypeDTO(e.getEventType(),
|
||||||
|
names.getOrDefault(e.getEventType(), e.getEventType())))
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -388,6 +388,27 @@ page:
|
|||||||
api_statistics:
|
api_statistics:
|
||||||
name: "이용 통계"
|
name: "이용 통계"
|
||||||
path: "/statistics/api"
|
path: "/statistics/api"
|
||||||
|
webhook:
|
||||||
|
name: "Webhook 관리"
|
||||||
|
path: "/webhook"
|
||||||
|
webhook_register_step1:
|
||||||
|
name: "Webhook 신청 (기본 정보)"
|
||||||
|
path: "/webhook/register/step1"
|
||||||
|
webhook_register_step2:
|
||||||
|
name: "Webhook 신청 (API 선택)"
|
||||||
|
path: "/webhook/register/step2"
|
||||||
|
webhook_register_step3:
|
||||||
|
name: "Webhook 신청 완료"
|
||||||
|
path: "/webhook/register/step3"
|
||||||
|
webhook_modify_step1:
|
||||||
|
name: "Webhook 수정 (기본 정보)"
|
||||||
|
path: "/webhook/modify/step1"
|
||||||
|
webhook_modify_step2:
|
||||||
|
name: "Webhook 수정 (API 선택)"
|
||||||
|
path: "/webhook/modify/step2"
|
||||||
|
webhook_modify_step3:
|
||||||
|
name: "Webhook 수정 완료"
|
||||||
|
path: "/webhook/modify/step3"
|
||||||
|
|
||||||
# 에디터 이미지 설정 (약관 이미지 표시용)
|
# 에디터 이미지 설정 (약관 이미지 표시용)
|
||||||
editor:
|
editor:
|
||||||
|
|||||||
@@ -19622,6 +19622,421 @@ input[type=checkbox]:checked + .custom-checkbox {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.webhook-container {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 60px;
|
||||||
|
}
|
||||||
|
.webhook-container .field-help {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.webhook-container .field-error,
|
||||||
|
.webhook-container .webhook-field-error {
|
||||||
|
color: #e03131;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.webhook-container .req {
|
||||||
|
color: #e03131;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 20px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
.webhook-empty .empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
.webhook-empty h3 {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.webhook-empty p {
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.webhook-steps .webhook-step {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.webhook-steps .webhook-step .step-no {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d5dbe6;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.webhook-steps .webhook-step.active {
|
||||||
|
background: rgba(11, 95, 255, 0.1);
|
||||||
|
color: #0b5fff;
|
||||||
|
}
|
||||||
|
.webhook-steps .webhook-step.active .step-no {
|
||||||
|
background: #0b5fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-field {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.webhook-field label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.webhook-field input[type=text],
|
||||||
|
.webhook-field input[type=password] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.webhook-field input[type=text]:focus,
|
||||||
|
.webhook-field input[type=password]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0b5fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-eventtype-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.webhook-eventtype-list .eventtype-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.webhook-eventtype-list .eventtype-item input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.webhook-eventtype-list .eventtype-item .eventtype-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.webhook-eventtype-list .eventtype-item .eventtype-code {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.webhook-eventtype-list .eventtype-item:hover {
|
||||||
|
border-color: #0b5fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-api-groups {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-api-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.webhook-api-group .group-name {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-api-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.webhook-api-list .webhook-api-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.webhook-api-list .webhook-api-card input {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
.webhook-api-list .webhook-api-card .api-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.webhook-api-list .webhook-api-card .api-id {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.webhook-api-list .webhook-api-card:hover {
|
||||||
|
border-color: #0b5fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
.webhook-actions.center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-webhook-next,
|
||||||
|
.btn-webhook-create {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #0b5fff;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-webhook-next:hover,
|
||||||
|
.btn-webhook-create:hover {
|
||||||
|
background: #0847c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-webhook-cancel {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #fff;
|
||||||
|
color: #6b7280;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.btn-webhook-cancel:hover {
|
||||||
|
border-color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-webhook-danger {
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #fff;
|
||||||
|
color: #e03131;
|
||||||
|
border: 1px solid #e03131;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-webhook-danger:hover {
|
||||||
|
background: #e03131;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #0b5fff;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-mini:hover {
|
||||||
|
background: rgba(11, 95, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-copy {
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: #0b5fff;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-copy:hover {
|
||||||
|
background: #0847c4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-card {
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-head h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-head .webhook-created {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid #e2e6ee;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-row .row-label {
|
||||||
|
width: 110px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-row .row-value {
|
||||||
|
flex: 1;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-row.secret-row, .webhook-card .webhook-card-row .secret-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-row code {
|
||||||
|
background: #f5f7fb;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.webhook-card .eventtype-badge,
|
||||||
|
.webhook-card .api-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
margin: 2px 4px 2px 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(11, 95, 255, 0.1);
|
||||||
|
color: #0b5fff;
|
||||||
|
}
|
||||||
|
.webhook-card .api-badge {
|
||||||
|
background: #f5f7fb;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.webhook-card .webhook-card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-complete {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.webhook-complete .complete-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
.webhook-complete h3 {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.webhook-complete .webhook-secret-box {
|
||||||
|
text-align: left;
|
||||||
|
max-width: 640px;
|
||||||
|
margin: 24px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f7fb;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.webhook-complete .webhook-secret-box .secret-warning {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fef3c7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.webhook-complete .webhook-secret-box label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.webhook-complete .webhook-secret-box .secret-value-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.webhook-complete .webhook-secret-box .secret-value-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.webhook-modal .webhook-modal-box {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 28px;
|
||||||
|
width: 360px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
.webhook-modal .webhook-modal-box h4 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.webhook-modal .webhook-modal-box p {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.webhook-modal .webhook-modal-box input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e2e6ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.webhook-modal .webhook-modal-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #0b5fff;
|
||||||
|
}
|
||||||
|
.webhook-modal .webhook-modal-box .webhook-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.webhook-modal .webhook-modal-box .webhook-modal-actions .btn-webhook-next, .webhook-modal .webhook-modal-box .webhook-modal-actions .btn-webhook-cancel {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.d-none {
|
.d-none {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|||||||
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
@@ -66,6 +66,7 @@
|
|||||||
@use 'pages/terms-agreements' as *;
|
@use 'pages/terms-agreements' as *;
|
||||||
@use 'pages/service' as *;
|
@use 'pages/service' as *;
|
||||||
@use 'pages/api-statistics' as *;
|
@use 'pages/api-statistics' as *;
|
||||||
|
@use 'pages/webhook' as *;
|
||||||
|
|
||||||
// 6. Themes
|
// 6. Themes
|
||||||
@use 'themes/dark' as *;
|
@use 'themes/dark' as *;
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// Webhook 신청/관리 (DJPGPT0004)
|
||||||
|
// App API Key 신청 3-step UI 톤을 답습한 자체 완결 스타일.
|
||||||
|
|
||||||
|
$wh-primary: #0b5fff;
|
||||||
|
$wh-primary-dark: #0847c4;
|
||||||
|
$wh-danger: #e03131;
|
||||||
|
$wh-border: #e2e6ee;
|
||||||
|
$wh-muted: #6b7280;
|
||||||
|
$wh-bg-soft: #f5f7fb;
|
||||||
|
|
||||||
|
.webhook-container {
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 16px 60px;
|
||||||
|
|
||||||
|
.field-help { color: $wh-muted; font-size: 13px; margin-top: 6px; }
|
||||||
|
.field-error,
|
||||||
|
.webhook-field-error { color: $wh-danger; font-size: 13px; margin-top: 6px; }
|
||||||
|
.req { color: $wh-danger; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 빈 상태 ----------
|
||||||
|
.webhook-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 20px;
|
||||||
|
background: $wh-bg-soft;
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 14px;
|
||||||
|
|
||||||
|
.empty-icon { font-size: 48px; }
|
||||||
|
h3 { margin: 16px 0 8px; font-size: 20px; }
|
||||||
|
p { color: $wh-muted; margin-bottom: 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 진행 표시 ----------
|
||||||
|
.webhook-steps {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
|
||||||
|
.webhook-step {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: $wh-bg-soft;
|
||||||
|
color: $wh-muted;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.step-no {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d5dbe6; color: #fff; font-size: 12px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba($wh-primary, 0.1);
|
||||||
|
color: $wh-primary;
|
||||||
|
.step-no { background: $wh-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 폼 ----------
|
||||||
|
.webhook-field {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
input[type="password"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
&:focus { outline: none; border-color: $wh-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-eventtype-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.eventtype-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input { width: 18px; height: 18px; }
|
||||||
|
.eventtype-name { font-weight: 600; }
|
||||||
|
.eventtype-code { color: $wh-muted; font-size: 12px; margin-left: auto; }
|
||||||
|
&:hover { border-color: $wh-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- API 선택 ----------
|
||||||
|
.webhook-api-groups { margin: 16px 0 8px; }
|
||||||
|
.webhook-api-group {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
.group-name { font-size: 16px; margin-bottom: 12px; }
|
||||||
|
}
|
||||||
|
.webhook-api-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
.webhook-api-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input { width: 18px; height: 18px; }
|
||||||
|
.api-name { font-weight: 600; }
|
||||||
|
.api-id { color: $wh-muted; font-size: 12px; margin-left: auto; }
|
||||||
|
&:hover { border-color: $wh-primary; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 액션 버튼 ----------
|
||||||
|
.webhook-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 32px;
|
||||||
|
&.center { justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-webhook-next,
|
||||||
|
.btn-webhook-create {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: $wh-primary; color: #fff;
|
||||||
|
border: none; border-radius: 10px;
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none;
|
||||||
|
&:hover { background: $wh-primary-dark; }
|
||||||
|
}
|
||||||
|
.btn-webhook-cancel {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #fff; color: $wh-muted;
|
||||||
|
border: 1px solid $wh-border; border-radius: 10px;
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer; text-decoration: none;
|
||||||
|
&:hover { border-color: $wh-muted; }
|
||||||
|
}
|
||||||
|
.btn-webhook-danger {
|
||||||
|
padding: 12px 28px;
|
||||||
|
background: #fff; color: $wh-danger;
|
||||||
|
border: 1px solid $wh-danger; border-radius: 10px;
|
||||||
|
font-size: 15px; font-weight: 600; cursor: pointer;
|
||||||
|
&:hover { background: $wh-danger; color: #fff; }
|
||||||
|
}
|
||||||
|
.btn-mini {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: $wh-bg-soft; color: $wh-primary;
|
||||||
|
border: 1px solid $wh-border; border-radius: 8px;
|
||||||
|
font-size: 12px; cursor: pointer;
|
||||||
|
&:hover { background: rgba($wh-primary, 0.1); }
|
||||||
|
}
|
||||||
|
.btn-copy {
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: $wh-primary; color: #fff;
|
||||||
|
border: none; border-radius: 8px; cursor: pointer;
|
||||||
|
&:hover { background: $wh-primary-dark; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 등록 카드 ----------
|
||||||
|
.webhook-card {
|
||||||
|
border: 1px solid $wh-border;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
.webhook-card-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
h3 { font-size: 18px; }
|
||||||
|
.webhook-created { color: $wh-muted; font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.webhook-card-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid $wh-border;
|
||||||
|
|
||||||
|
.row-label { width: 110px; color: $wh-muted; font-weight: 600; flex-shrink: 0; }
|
||||||
|
.row-value { flex: 1; word-break: break-all; }
|
||||||
|
&.secret-row, .secret-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||||
|
code { background: $wh-bg-soft; padding: 4px 8px; border-radius: 6px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.eventtype-badge,
|
||||||
|
.api-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px; margin: 2px 4px 2px 0;
|
||||||
|
border-radius: 999px; font-size: 12px;
|
||||||
|
background: rgba($wh-primary, 0.1); color: $wh-primary;
|
||||||
|
}
|
||||||
|
.api-badge { background: $wh-bg-soft; color: $wh-muted; }
|
||||||
|
|
||||||
|
.webhook-card-actions {
|
||||||
|
display: flex; gap: 12px; justify-content: flex-end;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 완료 화면 ----------
|
||||||
|
.webhook-complete {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
|
||||||
|
.complete-icon { font-size: 48px; }
|
||||||
|
h3 { margin: 16px 0; }
|
||||||
|
|
||||||
|
.webhook-secret-box {
|
||||||
|
text-align: left;
|
||||||
|
max-width: 640px; margin: 24px auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: $wh-bg-soft; border: 1px solid $wh-border; border-radius: 12px;
|
||||||
|
|
||||||
|
.secret-warning {
|
||||||
|
color: #92400e; background: #fef3c7;
|
||||||
|
border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; font-size: 13px;
|
||||||
|
}
|
||||||
|
label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.secret-value-row {
|
||||||
|
display: flex; gap: 8px;
|
||||||
|
input { flex: 1; padding: 12px 14px; border: 1px solid $wh-border; border-radius: 10px; font-family: monospace; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 비밀번호 모달 ----------
|
||||||
|
.webhook-modal {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
|
||||||
|
.webhook-modal-box {
|
||||||
|
background: #fff; border-radius: 14px;
|
||||||
|
padding: 28px; width: 360px; max-width: 90vw;
|
||||||
|
|
||||||
|
h4 { font-size: 18px; margin-bottom: 8px; }
|
||||||
|
p { color: $wh-muted; font-size: 14px; margin-bottom: 16px; }
|
||||||
|
input {
|
||||||
|
width: 100%; padding: 12px 14px;
|
||||||
|
border: 1px solid $wh-border; border-radius: 10px; margin-bottom: 8px;
|
||||||
|
&:focus { outline: none; border-color: $wh-primary; }
|
||||||
|
}
|
||||||
|
.webhook-modal-actions {
|
||||||
|
display: flex; gap: 12px; justify-content: flex-end; margin-top: 12px;
|
||||||
|
.btn-webhook-next, .btn-webhook-cancel { padding: 10px 20px; font-size: 14px; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="webhook-container">
|
||||||
|
<div class="webhook-empty">
|
||||||
|
<div class="empty-icon">🔔</div>
|
||||||
|
<h3>등록된 Webhook이 없습니다</h3>
|
||||||
|
<p>API 서비스의 점검·지연·장애 알림을 받을 Webhook을 신청해보세요.</p>
|
||||||
|
|
||||||
|
<div class="webhook-empty-action" sec:authorize="hasRole('ROLE_APP')">
|
||||||
|
<button type="button" class="btn-webhook-create" id="requestWebhook">
|
||||||
|
Webhook 신청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentScript">
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var btn = document.getElementById('requestWebhook');
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
window.location.href = '/webhook/register/step1?clear=true';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 관리</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="webhook-container">
|
||||||
|
<div class="webhook-card" th:object="${webhook}">
|
||||||
|
|
||||||
|
<div class="webhook-card-head">
|
||||||
|
<h3>등록된 Webhook</h3>
|
||||||
|
<span class="webhook-created" th:if="*{createdDate != null and #strings.length(createdDate) >= 8}"
|
||||||
|
th:text="|등록일 ${#strings.substring(webhook.createdDate,0,4)}-${#strings.substring(webhook.createdDate,4,6)}-${#strings.substring(webhook.createdDate,6,8)}|">등록일</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-row">
|
||||||
|
<span class="row-label">수신 URL</span>
|
||||||
|
<span class="row-value" th:text="*{targetUrl}">https://...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-row">
|
||||||
|
<span class="row-label">알림 이벤트</span>
|
||||||
|
<span class="row-value">
|
||||||
|
<span class="eventtype-badge" th:each="et : *{eventTypes}" th:text="${et.name}">이벤트</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-row">
|
||||||
|
<span class="row-label">대상 API</span>
|
||||||
|
<span class="row-value">
|
||||||
|
<span class="api-badge" th:each="apiId : *{apiIds}" th:text="${apiId}">API</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-row">
|
||||||
|
<span class="row-label">Secret Key</span>
|
||||||
|
<span class="row-value secret-row">
|
||||||
|
<code id="secretMasked" th:text="*{secretMasked}">••••••••</code>
|
||||||
|
<button type="button" class="btn-mini" id="btnRevealSecret">조회</button>
|
||||||
|
<button type="button" class="btn-mini" id="btnRegenSecret">재발급</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-card-actions">
|
||||||
|
<a class="btn-webhook-next" th:href="@{/webhook/modify/step1}">수정</a>
|
||||||
|
<button type="button" class="btn-webhook-danger" id="btnDeleteWebhook">삭제</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 비밀번호 재인증 모달 -->
|
||||||
|
<div class="webhook-modal" id="pwModal" style="display:none;">
|
||||||
|
<div class="webhook-modal-box">
|
||||||
|
<h4 id="pwModalTitle">비밀번호 확인</h4>
|
||||||
|
<p id="pwModalDesc">계속하려면 비밀번호를 입력하세요.</p>
|
||||||
|
<input type="password" id="pwModalInput" placeholder="비밀번호" autocomplete="current-password">
|
||||||
|
<p class="field-error" id="pwModalError" style="display:none;"></p>
|
||||||
|
<div class="webhook-modal-actions">
|
||||||
|
<button type="button" class="btn-webhook-cancel" id="pwModalCancel">취소</button>
|
||||||
|
<button type="button" class="btn-webhook-next" id="pwModalConfirm">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentScript">
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var csrfToken = document.querySelector('meta[name="_csrf"]');
|
||||||
|
var csrfHeaderMeta = document.querySelector('meta[name="_csrf_header"]');
|
||||||
|
var CSRF_TOKEN = csrfToken ? csrfToken.getAttribute('content') : '';
|
||||||
|
var CSRF_HEADER = csrfHeaderMeta ? csrfHeaderMeta.getAttribute('content') : 'X-XSRF-TOKEN';
|
||||||
|
|
||||||
|
var modal = document.getElementById('pwModal');
|
||||||
|
var input = document.getElementById('pwModalInput');
|
||||||
|
var errorEl = document.getElementById('pwModalError');
|
||||||
|
var titleEl = document.getElementById('pwModalTitle');
|
||||||
|
var descEl = document.getElementById('pwModalDesc');
|
||||||
|
var pendingAction = null;
|
||||||
|
|
||||||
|
function openModal(title, desc, action) {
|
||||||
|
titleEl.textContent = title;
|
||||||
|
descEl.textContent = desc;
|
||||||
|
input.value = '';
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
pendingAction = action;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
function closeModal() { modal.style.display = 'none'; pendingAction = null; }
|
||||||
|
|
||||||
|
document.getElementById('pwModalCancel').addEventListener('click', closeModal);
|
||||||
|
document.getElementById('pwModalConfirm').addEventListener('click', function () {
|
||||||
|
if (pendingAction) { pendingAction(input.value); }
|
||||||
|
});
|
||||||
|
input.addEventListener('keyup', function (e) {
|
||||||
|
if (e.key === 'Enter' && pendingAction) { pendingAction(input.value); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function post(url, password) {
|
||||||
|
var headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
|
||||||
|
headers[CSRF_HEADER] = CSRF_TOKEN;
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: headers,
|
||||||
|
body: 'password=' + encodeURIComponent(password)
|
||||||
|
}).then(function (r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||||
|
|
||||||
|
// Secret 조회
|
||||||
|
document.getElementById('btnRevealSecret').addEventListener('click', function () {
|
||||||
|
openModal('Secret 조회', '비밀번호 확인 후 Secret Key를 표시합니다.', function (pw) {
|
||||||
|
post('/webhook/verify-secret', pw).then(function (res) {
|
||||||
|
if (res.success) {
|
||||||
|
document.getElementById('secretMasked').textContent = res.secret;
|
||||||
|
closeModal();
|
||||||
|
} else { showError(res.message || '실패했습니다.'); }
|
||||||
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Secret 재발급
|
||||||
|
document.getElementById('btnRegenSecret').addEventListener('click', function () {
|
||||||
|
openModal('Secret 재발급', '재발급 시 기존 Secret은 무효화됩니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
|
||||||
|
post('/webhook/regenerate-secret', pw).then(function (res) {
|
||||||
|
if (res.success) {
|
||||||
|
document.getElementById('secretMasked').textContent = res.secret;
|
||||||
|
closeModal();
|
||||||
|
alert('Secret이 재발급되었습니다. 새 값을 안전하게 보관하세요.');
|
||||||
|
} else { showError(res.message || '실패했습니다.'); }
|
||||||
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 삭제
|
||||||
|
document.getElementById('btnDeleteWebhook').addEventListener('click', function () {
|
||||||
|
openModal('Webhook 삭제', '삭제하면 복구할 수 없습니다. 계속하려면 비밀번호를 입력하세요.', function (pw) {
|
||||||
|
post('/webhook/delete', pw).then(function (res) {
|
||||||
|
if (res.success) { window.location.href = '/webhook'; }
|
||||||
|
else { showError(res.message || '실패했습니다.'); }
|
||||||
|
}).catch(function () { showError('요청 처리 중 오류가 발생했습니다.'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 수정</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="webhook-container webhook-register">
|
||||||
|
|
||||||
|
<div class="webhook-steps" th:with="cur=${currentStep}">
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
|
||||||
|
<span class="step-no">1</span><span class="step-label">기본 정보</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
|
||||||
|
<span class="step-no">2</span><span class="step-label">API 선택</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
|
||||||
|
<span class="step-no">3</span><span class="step-label">완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="modifyStep1Form" method="post" th:action="@{/webhook/modify/step1}" th:object="${webhookModification}">
|
||||||
|
|
||||||
|
<div class="webhook-field">
|
||||||
|
<label for="targetUrl">Webhook 수신 URL <span class="req">*</span></label>
|
||||||
|
<input type="text" id="targetUrl" th:field="*{targetUrl}"
|
||||||
|
placeholder="https://example.com/webhook" autocomplete="off">
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
|
||||||
|
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-field">
|
||||||
|
<label>알림 받을 이벤트 <span class="req">*</span></label>
|
||||||
|
<div class="webhook-eventtype-list">
|
||||||
|
<label class="eventtype-item" th:each="et : ${eventTypes}">
|
||||||
|
<input type="checkbox" name="eventTypes" th:value="${et.code}"
|
||||||
|
th:checked="${#lists.contains(webhookModification.eventTypes, et.code)}">
|
||||||
|
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
|
||||||
|
<span class="eventtype-code" th:text="${et.code}">CODE</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-actions">
|
||||||
|
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/cancel}">취소</a>
|
||||||
|
<button type="submit" class="btn-webhook-next">다음</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 수정</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="webhook-container webhook-register">
|
||||||
|
|
||||||
|
<div class="webhook-steps" th:with="cur=${currentStep}">
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
|
||||||
|
<span class="step-no">1</span><span class="step-label">기본 정보</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
|
||||||
|
<span class="step-no">2</span><span class="step-label">API 선택</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
|
||||||
|
<span class="step-no">3</span><span class="step-label">완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="webhook-field-error" th:if="${error}" th:text="${error}"></p>
|
||||||
|
|
||||||
|
<form id="modifyStep2Form" method="post" th:action="@{/webhook/modify/step2}">
|
||||||
|
<p class="field-help">이 Webhook으로 상태 알림을 받을 API를 선택하세요. (1개 이상)</p>
|
||||||
|
|
||||||
|
<div class="webhook-api-groups">
|
||||||
|
<div class="webhook-api-group" th:each="group : ${apiGroups}">
|
||||||
|
<h3 class="group-name" th:text="${group.groupName}">그룹명</h3>
|
||||||
|
<div class="webhook-api-list">
|
||||||
|
<label class="webhook-api-card" th:each="api : ${group.apiGroupApiList}">
|
||||||
|
<input type="checkbox" name="selectedApis" th:value="${api.apiId}"
|
||||||
|
th:checked="${selectedApis != null and #lists.contains(selectedApis, api.apiId)}">
|
||||||
|
<span class="api-name" th:text="${api.apiName}">API 이름</span>
|
||||||
|
<span class="api-id" th:text="${api.apiId}">API ID</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-actions">
|
||||||
|
<a class="btn-webhook-cancel" th:href="@{/webhook/modify/step1}">이전</a>
|
||||||
|
<button type="submit" class="btn-webhook-next">수정 완료</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 수정 완료</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="webhook-container webhook-register">
|
||||||
|
|
||||||
|
<div class="webhook-steps">
|
||||||
|
<div class="webhook-step active"><span class="step-no">1</span><span class="step-label">기본 정보</span></div>
|
||||||
|
<div class="webhook-step active"><span class="step-no">2</span><span class="step-label">API 선택</span></div>
|
||||||
|
<div class="webhook-step active"><span class="step-no">3</span><span class="step-label">완료</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-complete">
|
||||||
|
<div class="complete-icon">✅</div>
|
||||||
|
<h3>Webhook 설정이 수정되었습니다</h3>
|
||||||
|
<p class="field-help">Secret Key는 변경되지 않았습니다.</p>
|
||||||
|
|
||||||
|
<div class="webhook-actions center">
|
||||||
|
<a class="btn-webhook-next" th:href="@{/webhook}">완료</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 신청</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="webhook-container webhook-register">
|
||||||
|
|
||||||
|
<!-- 진행 표시 -->
|
||||||
|
<div class="webhook-steps" th:with="cur=${currentStep}">
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
|
||||||
|
<span class="step-no">1</span><span class="step-label">기본 정보</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
|
||||||
|
<span class="step-no">2</span><span class="step-label">API 선택</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
|
||||||
|
<span class="step-no">3</span><span class="step-label">완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="registerStep1Form" method="post" th:action="@{/webhook/register/step1}" th:object="${webhookRegistration}">
|
||||||
|
|
||||||
|
<div class="webhook-field">
|
||||||
|
<label for="targetUrl">Webhook 수신 URL <span class="req">*</span></label>
|
||||||
|
<input type="text" id="targetUrl" th:field="*{targetUrl}"
|
||||||
|
placeholder="https://example.com/webhook" autocomplete="off">
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('targetUrl')}" th:errors="*{targetUrl}">URL 오류</p>
|
||||||
|
<p class="field-help">이벤트 발생 시 이 URL로 서명된 POST 요청이 전송됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-field">
|
||||||
|
<label>알림 받을 이벤트 <span class="req">*</span></label>
|
||||||
|
<div class="webhook-eventtype-list">
|
||||||
|
<label class="eventtype-item" th:each="et : ${eventTypes}">
|
||||||
|
<input type="checkbox" name="eventTypes" th:value="${et.code}"
|
||||||
|
th:checked="${#lists.contains(webhookRegistration.eventTypes, et.code)}">
|
||||||
|
<span class="eventtype-name" th:text="${et.name}">이벤트명</span>
|
||||||
|
<span class="eventtype-code" th:text="${et.code}">CODE</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="field-error" th:if="${#fields.hasErrors('eventTypes')}" th:errors="*{eventTypes}">이벤트 오류</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-actions">
|
||||||
|
<a class="btn-webhook-cancel" th:href="@{/webhook/register/cancel}">취소</a>
|
||||||
|
<button type="submit" class="btn-webhook-next">다음</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 신청</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="webhook-container webhook-register">
|
||||||
|
|
||||||
|
<div class="webhook-steps" th:with="cur=${currentStep}">
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 1} ? 'active'">
|
||||||
|
<span class="step-no">1</span><span class="step-label">기본 정보</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 2} ? 'active'">
|
||||||
|
<span class="step-no">2</span><span class="step-label">API 선택</span>
|
||||||
|
</div>
|
||||||
|
<div class="webhook-step" th:classappend="${cur >= 3} ? 'active'">
|
||||||
|
<span class="step-no">3</span><span class="step-label">완료</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="webhook-field-error" th:if="${error}" th:text="${error}"></p>
|
||||||
|
|
||||||
|
<form id="registerStep2Form" method="post" th:action="@{/webhook/register/step2}">
|
||||||
|
<p class="field-help">이 Webhook으로 상태 알림을 받을 API를 선택하세요. (1개 이상)</p>
|
||||||
|
|
||||||
|
<div class="webhook-api-groups">
|
||||||
|
<div class="webhook-api-group" th:each="group : ${apiGroups}">
|
||||||
|
<h3 class="group-name" th:text="${group.groupName}">그룹명</h3>
|
||||||
|
<div class="webhook-api-list">
|
||||||
|
<label class="webhook-api-card"
|
||||||
|
th:each="api : ${group.apiGroupApiList}">
|
||||||
|
<input type="checkbox" name="selectedApis" th:value="${api.apiId}"
|
||||||
|
th:checked="${selectedApis != null and #lists.contains(selectedApis, api.apiId)}">
|
||||||
|
<span class="api-name" th:text="${api.apiName}">API 이름</span>
|
||||||
|
<span class="api-id" th:text="${api.apiId}">API ID</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-actions">
|
||||||
|
<a class="btn-webhook-cancel" th:href="@{/webhook/register/step1}">이전</a>
|
||||||
|
<button type="submit" class="btn-webhook-next">신청 완료</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{layout/djbank_title_layout}">
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<section layout:fragment="title">
|
||||||
|
<div class="page-title-banner">
|
||||||
|
<img th:src="@{/img/img_title_bg.png}" class="title-image">
|
||||||
|
<h1>Webhook 신청 완료</h1>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentFragment">
|
||||||
|
<section class="webhook-container webhook-register">
|
||||||
|
|
||||||
|
<div class="webhook-steps">
|
||||||
|
<div class="webhook-step active"><span class="step-no">1</span><span class="step-label">기본 정보</span></div>
|
||||||
|
<div class="webhook-step active"><span class="step-no">2</span><span class="step-label">API 선택</span></div>
|
||||||
|
<div class="webhook-step active"><span class="step-no">3</span><span class="step-label">완료</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-complete">
|
||||||
|
<div class="complete-icon">✅</div>
|
||||||
|
<h3>Webhook이 등록되었습니다</h3>
|
||||||
|
|
||||||
|
<div class="webhook-secret-box">
|
||||||
|
<div class="secret-warning">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
아래 Secret Key는 <strong>지금 한 번만</strong> 전체가 표시됩니다. 안전한 곳에 보관하세요.
|
||||||
|
(분실 시 재발급 필요)
|
||||||
|
</div>
|
||||||
|
<label>Secret Key</label>
|
||||||
|
<div class="secret-value-row">
|
||||||
|
<input type="text" id="secretValue" th:value="${secret}" readonly>
|
||||||
|
<button type="button" class="btn-copy" id="copySecret">복사</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="webhook-actions center">
|
||||||
|
<a class="btn-webhook-next" th:href="@{/webhook}">완료</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</th:block>
|
||||||
|
|
||||||
|
<th:block layout:fragment="contentScript">
|
||||||
|
<script th:inline="javascript">
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var btn = document.getElementById('copySecret');
|
||||||
|
var input = document.getElementById('secretValue');
|
||||||
|
if (btn && input) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
input.select();
|
||||||
|
input.setSelectionRange(0, 99999);
|
||||||
|
navigator.clipboard.writeText(input.value).then(function () {
|
||||||
|
btn.textContent = '복사됨';
|
||||||
|
setTimeout(function () { btn.textContent = '복사'; }, 1500);
|
||||||
|
}).catch(function () {
|
||||||
|
document.execCommand('copy');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</th:block>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
<li sec:authorize="hasRole('ROLE_APP')">
|
<li sec:authorize="hasRole('ROLE_APP')">
|
||||||
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
|
<a th:href="@{/myapikey}"><i class="fas fa-key"></i>인증 키 관리</a>
|
||||||
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
|
<a th:href="@{/statistics/api}"><i class="fas fa-chart-bar"></i>이용 통계</a>
|
||||||
|
<a th:href="@{/webhook}"><i class="fas fa-bell"></i>Webhook 관리</a>
|
||||||
</li>
|
</li>
|
||||||
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
<li><a th:href="@{/mypage}"><i class="fas fa-user-circle"></i>내 정보 관리</a></li>
|
||||||
<li><a th:href="@{/change_password}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
|
<li><a th:href="@{/change_password}"><i class="fas fa-lock"></i>비밀번호 변경</a></li>
|
||||||
@@ -222,6 +223,7 @@
|
|||||||
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_USER_MANAGER')"><a th:href="@{/users}">이용자 관리</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
|
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/myapikey}">인증 키 관리</a></li>
|
||||||
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
|
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/statistics/api}">이용 통계</a></li>
|
||||||
|
<li sec:authorize="hasRole('ROLE_APP')"><a th:href="@{/webhook}">Webhook 관리</a></li>
|
||||||
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
|
<li><a th:href="@{/mypage}">내 정보 관리</a></li>
|
||||||
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
|
<li><a th:href="@{/change_password}">비밀번호 변경</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user