diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 56691cb..0e8e0e8 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -15,6 +15,14 @@ - Bypass API 카탈로그 Swagger 딥링크 연동 (#142) - 카탈로그 테스트 버튼 클릭 시 해당 API로 Swagger UI 딥링크 이동 - Swagger UI deep-linking 활성화 +- Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140) + - 이메일 공통 모듈 (EmailService, Thymeleaf HTML 템플릿) + - 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송 + - 재심사 기능 (REJECTED → PENDING) + +### 변경 +- UI 텍스트 리레이블링: S&P Bypass → S&P Global API +- 신청 폼 전화번호 필드 제거 및 레이아웃 개선 ## [2026-04-02] diff --git a/frontend/src/api/bypassAccountApi.ts b/frontend/src/api/bypassAccountApi.ts index aae6f5e..0dae666 100644 --- a/frontend/src/api/bypassAccountApi.ts +++ b/frontend/src/api/bypassAccountApi.ts @@ -135,4 +135,6 @@ export const bypassAccountApi = { postJson>(`${BASE}/requests/${id}/approve`, data), rejectRequest: (id: number, data: BypassRequestReviewRequest) => postJson>(`${BASE}/requests/${id}/reject`, data), + reopenRequest: (id: number) => + postJson>(`${BASE}/requests/${id}/reopen`, {}), }; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index e129ed9..c1cf196 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -38,11 +38,11 @@ const MENU_STRUCTURE: MenuSection[] = [ }, { id: 'bypass', - label: 'S&P Bypass', - shortLabel: 'Bypass', + label: 'S&P Global API', + shortLabel: 'S&P Global API', icon: ( - + ), defaultPath: '/bypass-catalog', @@ -51,7 +51,7 @@ const MENU_STRUCTURE: MenuSection[] = [ { id: 'bypass-config', label: 'API 관리', path: '/bypass-config' }, { id: 'bypass-account-requests', label: '계정 신청 관리', path: '/bypass-account-requests' }, { id: 'bypass-account-management', label: '계정 관리', path: '/bypass-account-management' }, - { id: 'bypass-access-request', label: 'API 접근 신청', path: '/bypass-access-request' }, + { id: 'bypass-access-request', label: 'API 계정 신청', path: '/bypass-access-request' }, ], }, { diff --git a/frontend/src/pages/BypassAccessRequest.tsx b/frontend/src/pages/BypassAccessRequest.tsx index b0c0d58..892ca55 100644 --- a/frontend/src/pages/BypassAccessRequest.tsx +++ b/frontend/src/pages/BypassAccessRequest.tsx @@ -11,21 +11,18 @@ interface FormState { organization: string; purpose: string; email: string; - phone: string; requestedAccessPeriod: string; } interface ErrorState { applicantName?: string; email?: string; - phone?: string; requestedAccessPeriod?: string; } interface TouchedState { applicantName: boolean; email: boolean; - phone: boolean; requestedAccessPeriod: boolean; } @@ -36,26 +33,16 @@ const INITIAL_FORM: FormState = { organization: '', purpose: '', email: '', - phone: '', requestedAccessPeriod: '', }; const INITIAL_TOUCHED: TouchedState = { applicantName: false, email: false, - phone: false, requestedAccessPeriod: false, }; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; -const PHONE_DIGITS_REGEX = /^\d{11}$/; - -function formatPhoneDisplay(digits: string): string { - const d = digits.slice(0, 11); - if (d.length <= 3) return d; - if (d.length <= 7) return `${d.slice(0, 3)}-${d.slice(3)}`; - return `${d.slice(0, 3)}-${d.slice(3, 7)}-${d.slice(7)}`; -} function toDateString(date: Date): string { const y = date.getFullYear(); @@ -89,7 +76,7 @@ function calcPresetPeriod(preset: PeriodPreset): string { return `${from} ~ ${to}`; } -function validateForm(form: FormState, phoneRaw: string): ErrorState { +function validateForm(form: FormState): ErrorState { const errors: ErrorState = {}; if (!form.applicantName.trim()) { @@ -102,12 +89,8 @@ function validateForm(form: FormState, phoneRaw: string): ErrorState { errors.email = '올바른 이메일 형식을 입력해주세요.'; } - if (phoneRaw && !PHONE_DIGITS_REGEX.test(phoneRaw)) { - errors.phone = '전화번호는 11자리 숫자로 입력해주세요. (예: 01012345678)'; - } - if (!form.requestedAccessPeriod.trim()) { - errors.requestedAccessPeriod = '요청 접근 기간을 선택해주세요.'; + errors.requestedAccessPeriod = '사용 기간을 선택해주세요.'; } return errors; @@ -118,8 +101,6 @@ export default function BypassAccessRequest() { const navigate = useNavigate(); const [form, setForm] = useState(INITIAL_FORM); - const [phoneRaw, setPhoneRaw] = useState(''); - const [phoneDisplay, setPhoneDisplay] = useState(''); const [submitting, setSubmitting] = useState(false); const [submittedId, setSubmittedId] = useState(null); @@ -139,16 +120,9 @@ export default function BypassAccessRequest() { setForm((prev) => ({ ...prev, [field]: value })); }; - const handlePhoneChange = (e: React.ChangeEvent) => { - const digits = e.target.value.replace(/\D/g, '').slice(0, 11); - setPhoneRaw(digits); - setPhoneDisplay(formatPhoneDisplay(digits)); - setForm((prev) => ({ ...prev, phone: digits })); - }; - const handleBlur = (field: keyof TouchedState) => { setTouched((prev) => ({ ...prev, [field]: true })); - const currentErrors = validateForm(form, phoneRaw); + const currentErrors = validateForm(form); setErrors(currentErrors); }; @@ -204,9 +178,9 @@ export default function BypassAccessRequest() { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitAttempted(true); - setTouched({ applicantName: true, email: true, phone: true, requestedAccessPeriod: true }); + setTouched({ applicantName: true, email: true, requestedAccessPeriod: true }); - const currentErrors = validateForm(form, phoneRaw); + const currentErrors = validateForm(form); if (periodManual && periodRangeError) { return; @@ -228,7 +202,7 @@ export default function BypassAccessRequest() { organization: form.organization, purpose: form.purpose, email: form.email, - phone: form.phone, + phone: '', requestedAccessPeriod: form.requestedAccessPeriod, }; const res = await bypassAccountApi.submitRequest(requestData); @@ -258,9 +232,9 @@ export default function BypassAccessRequest() {
{/* 헤더 */}
-

Bypass API 접근 신청

+

S&P API 계정 신청

- Bypass API 사용 계정을 신청합니다. 검토 후 계정 정보가 이메일로 발송됩니다. + S&P API 계정을 신청합니다. 검토 후 계정 정보가 이메일로 발송됩니다.

@@ -283,8 +257,6 @@ export default function BypassAccessRequest() { type="button" onClick={() => { setForm(INITIAL_FORM); - setPhoneRaw(''); - setPhoneDisplay(''); setErrors({}); setTouched(INITIAL_TOUCHED); setSubmitAttempted(false); @@ -304,7 +276,7 @@ export default function BypassAccessRequest() { onClick={() => navigate('/bypass-catalog')} className="mt-3 px-6 py-2 text-sm font-medium text-wing-text bg-wing-card hover:bg-wing-hover border border-wing-border rounded-lg transition-colors" > - Bypass API 목록 보기 + S&P API 목록 보기
) : ( @@ -346,7 +318,7 @@ export default function BypassAccessRequest() { - {/* 이메일 + 전화번호 */} + {/* 이메일 + 사용 기간 */}
-
- - handleBlur('phone')} - placeholder="010-0000-0000" - disabled={submitting} - className={inputClass('phone')} - /> - {showError('phone') && ( -

{errors.phone}

+
+
+ + +
+ {!periodManual ? ( +
+ {PRESETS.map((preset) => ( + + ))} +
+ ) : ( +
+
+ handlePeriodFromChange(e.target.value)} + disabled={submitting} + className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50" + /> +
+ ~ +
+ handlePeriodToChange(e.target.value)} + disabled={submitting} + className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50" + /> +
+
+ )} + + {form.requestedAccessPeriod && !periodRangeError && ( +

+ 선택된 기간: {form.requestedAccessPeriod} +

+ )} + {periodRangeError && ( +

{periodRangeError}

+ )} + {showError('requestedAccessPeriod') && !periodRangeError && ( +

{errors.requestedAccessPeriod}

)}
@@ -399,89 +432,6 @@ export default function BypassAccessRequest() { />
- {/* 요청 접근 기간 */} -
- -
- -
- - {!periodManual ? ( - /* Preset buttons */ -
- {PRESETS.map((preset) => ( - - ))} -
- ) : ( - /* Manual date range inputs */ -
-
- handlePeriodFromChange(e.target.value)} - disabled={submitting} - className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50" - /> -
- ~ -
- handlePeriodToChange(e.target.value)} - disabled={submitting} - className="w-full px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text focus:outline-none focus:ring-2 focus:ring-wing-accent/50" - /> -
-
- )} - - {/* Period preview */} - {form.requestedAccessPeriod && !periodRangeError && ( -

- 선택된 기간: {form.requestedAccessPeriod} -

- )} - {periodRangeError && ( -

{periodRangeError}

- )} - {showError('requestedAccessPeriod') && !periodRangeError && ( -

{errors.requestedAccessPeriod}

- )} -
diff --git a/frontend/src/pages/BypassAccountManagement.tsx b/frontend/src/pages/BypassAccountManagement.tsx index 2874884..3a82686 100644 --- a/frontend/src/pages/BypassAccountManagement.tsx +++ b/frontend/src/pages/BypassAccountManagement.tsx @@ -337,10 +337,6 @@ export default function BypassAccountManagement() { 이메일: {editTarget.email ?? '-'}
-
- 전화번호: - {editTarget.phone ?? '-'} -
diff --git a/frontend/src/pages/BypassAccountRequests.tsx b/frontend/src/pages/BypassAccountRequests.tsx index 77ec7c7..ffe9571 100644 --- a/frontend/src/pages/BypassAccountRequests.tsx +++ b/frontend/src/pages/BypassAccountRequests.tsx @@ -293,14 +293,10 @@ export default function BypassAccountRequests() { 이메일: {approveTarget.email ?? '-'} -
- 전화번호: - {approveTarget.phone ?? '-'} -
-
- 요청 기간: - {approveTarget.requestedAccessPeriod ?? '-'} -
+ +
+ 요청 기간: + {approveTarget.requestedAccessPeriod ?? '-'}
{approveTarget.purpose && (
@@ -396,14 +392,10 @@ export default function BypassAccountRequests() { 이메일: {rejectTarget.email ?? '-'}
-
- 전화번호: - {rejectTarget.phone ?? '-'} -
-
- 요청 기간: - {rejectTarget.requestedAccessPeriod ?? '-'} -
+ +
+ 요청 기간: + {rejectTarget.requestedAccessPeriod ?? '-'}
{rejectTarget.purpose && (
@@ -485,10 +477,6 @@ export default function BypassAccountRequests() {
이메일
{detailTarget.email ?? '-'}
-
-
전화번호
-
{detailTarget.phone ?? '-'}
-
요청 접근 기간
@@ -549,6 +537,25 @@ export default function BypassAccountRequests() { )} + {detailTarget.status === 'REJECTED' && ( + + )}
diff --git a/frontend/src/pages/BypassCatalog.tsx b/frontend/src/pages/BypassCatalog.tsx index eb2279a..c495306 100644 --- a/frontend/src/pages/BypassCatalog.tsx +++ b/frontend/src/pages/BypassCatalog.tsx @@ -92,9 +92,9 @@ export default function BypassCatalog() { {/* 헤더 */}
-

Bypass API 카탈로그

+

S&P Global API 카탈로그

- 등록된 Bypass API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다. + S&P Global Maritime API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.

spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-mail + + org.springframework.boot diff --git a/src/main/java/com/snp/batch/global/controller/BypassAccountController.java b/src/main/java/com/snp/batch/global/controller/BypassAccountController.java index 1573548..5a7911b 100644 --- a/src/main/java/com/snp/batch/global/controller/BypassAccountController.java +++ b/src/main/java/com/snp/batch/global/controller/BypassAccountController.java @@ -114,4 +114,10 @@ public class BypassAccountController { @RequestBody BypassRequestReviewRequest review) { return ResponseEntity.ok(ApiResponse.success(requestService.rejectRequest(id, review))); } + + @PostMapping("/requests/{id}/reopen") + @Operation(summary = "신청 재심사 (거절 → 대기)") + public ResponseEntity> reopenRequest(@PathVariable Long id) { + return ResponseEntity.ok(ApiResponse.success(requestService.reopenRequest(id))); + } } diff --git a/src/main/java/com/snp/batch/service/BypassApiRequestService.java b/src/main/java/com/snp/batch/service/BypassApiRequestService.java index 6638424..395889f 100644 --- a/src/main/java/com/snp/batch/service/BypassApiRequestService.java +++ b/src/main/java/com/snp/batch/service/BypassApiRequestService.java @@ -26,6 +26,7 @@ public class BypassApiRequestService { private final BypassApiRequestRepository requestRepository; private final BypassApiAccountRepository accountRepository; private final BypassApiAccountService accountService; + private final EmailService emailService; @Transactional public BypassRequestResponse submitRequest(BypassRequestSubmitRequest request) { @@ -82,6 +83,18 @@ public class BypassApiRequestService { request.setAccount(accountRepository.getReferenceById(accountResponse.getId())); requestRepository.save(request); + // 이메일 발송 (비동기) + if (request.getEmail() != null && !request.getEmail().isBlank()) { + emailService.sendAccountApprovedEmail( + request.getEmail(), + request.getApplicantName(), + accountResponse.getUsername(), + accountResponse.getPlainPassword(), + review.getAccessStartDate() != null ? review.getAccessStartDate().toString() : null, + review.getAccessEndDate() != null ? review.getAccessEndDate().toString() : null + ); + } + log.info("Bypass API 계정 신청 승인: requestId={}, accountUsername={}", id, accountResponse.getUsername()); return accountResponse; @@ -100,10 +113,36 @@ public class BypassApiRequestService { request.setRejectReason(review.getRejectReason()); BypassApiRequest saved = requestRepository.save(request); + // 이메일 발송 (비동기) + if (request.getEmail() != null && !request.getEmail().isBlank()) { + emailService.sendRequestRejectedEmail( + request.getEmail(), + request.getApplicantName(), + review.getRejectReason() + ); + } + log.info("Bypass API 계정 신청 거절: requestId={}", id); return toResponse(saved); } + @Transactional + public BypassRequestResponse reopenRequest(Long id) { + BypassApiRequest request = findOrThrow(id); + if (request.getStatus() != RequestStatus.REJECTED) { + throw new IllegalStateException("거절된 신청만 재심사할 수 있습니다: " + request.getStatus()); + } + + request.setStatus(RequestStatus.PENDING); + request.setReviewedBy(null); + request.setReviewedAt(null); + request.setRejectReason(null); + BypassApiRequest saved = requestRepository.save(request); + + log.info("Bypass API 계정 신청 재심사: requestId={}", id); + return toResponse(saved); + } + private BypassApiRequest findOrThrow(Long id) { return requestRepository.findById(id) .orElseThrow(() -> new IllegalArgumentException("신청을 찾을 수 없습니다: " + id)); diff --git a/src/main/java/com/snp/batch/service/EmailService.java b/src/main/java/com/snp/batch/service/EmailService.java new file mode 100644 index 0000000..9a45d80 --- /dev/null +++ b/src/main/java/com/snp/batch/service/EmailService.java @@ -0,0 +1,84 @@ +package com.snp.batch.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +/** + * 이메일 발송 공통 서비스 + * + * 비동기(@Async)로 발송하여 API 응답 지연을 방지한다. + * Thymeleaf 템플릿 엔진으로 HTML 이메일을 구성한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + private final TemplateEngine templateEngine; + + @Value("${spring.mail.username:noreply@example.com}") + private String fromAddress; + + @Value("${app.base-url:https://guide.gc-si.dev/snp-api}") + private String baseUrl; + + /** + * Bypass API 계정 발급 이메일 발송 + */ + @Async + public void sendAccountApprovedEmail(String toEmail, String applicantName, + String username, String plainPassword, + String accessStartDate, String accessEndDate) { + Context context = new Context(); + context.setVariable("applicantName", applicantName); + context.setVariable("username", username); + context.setVariable("password", plainPassword); + context.setVariable("accessStartDate", accessStartDate != null ? accessStartDate : "-"); + context.setVariable("accessEndDate", accessEndDate != null ? accessEndDate : "-"); + context.setVariable("swaggerUrl", baseUrl + "/swagger-ui/index.html?urls.primaryName=3.%20Bypass%20API"); + + String html = templateEngine.process("email/account-approved", context); + sendHtmlEmail(toEmail, "[S&P Data Platform] S&P API 계정이 발급되었습니다", html); + } + + /** + * Bypass API 계정 신청 거절 이메일 발송 + */ + @Async + public void sendRequestRejectedEmail(String toEmail, String applicantName, + String rejectReason) { + Context context = new Context(); + context.setVariable("applicantName", applicantName); + context.setVariable("rejectReason", rejectReason != null && !rejectReason.isBlank() + ? rejectReason : "별도 안내 없음"); + context.setVariable("requestUrl", baseUrl + "/bypass-access-request"); + + String html = templateEngine.process("email/request-rejected", context); + sendHtmlEmail(toEmail, "[S&P Data Platform] S&P API 계정 신청이 거절되었습니다", html); + } + + private void sendHtmlEmail(String to, String subject, String htmlContent) { + try { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setFrom(fromAddress); + helper.setTo(to); + helper.setSubject(subject); + helper.setText(htmlContent, true); + mailSender.send(message); + log.info("이메일 발송 완료: to={}, subject={}", to, subject); + } catch (MessagingException e) { + log.error("이메일 발송 실패: to={}, subject={}", to, subject, e); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index ce28d9a..d2f0ef0 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -38,6 +38,17 @@ spring: prefix: classpath:/templates/ suffix: .html + # Email (SMTP) + mail: + host: smtp.gmail.com + port: 587 + username: hjkim4@gcsc.co.kr + password: khow urga yyxh rciq + properties: + mail.smtp.auth: true + mail.smtp.starttls.enable: true + mail.smtp.starttls.required: true + # Quartz Scheduler Configuration - Using JDBC Store for persistence quartz: job-store-type: jdbc # JDBC store for schedule persistence diff --git a/src/main/resources/templates/email/account-approved.html b/src/main/resources/templates/email/account-approved.html new file mode 100644 index 0000000..76acbeb --- /dev/null +++ b/src/main/resources/templates/email/account-approved.html @@ -0,0 +1,63 @@ + + + + + + + + + diff --git a/src/main/resources/templates/email/request-rejected.html b/src/main/resources/templates/email/request-rejected.html new file mode 100644 index 0000000..891e427 --- /dev/null +++ b/src/main/resources/templates/email/request-rejected.html @@ -0,0 +1,50 @@ + + + + + + +
+ +
+

S&P Data Platform

+
+ + +
+

+ 안녕하세요, 신청자님. +

+

+ S&P API 계정 신청이 거절되었습니다. +

+ + +
+

거절 사유

+

거절 사유 내용

+
+ +

+ 추가 문의 사항이 있으시면 관리자에게 연락해주세요.
+ 신청서를 수정하여 다시 제출하실 수 있습니다. +

+ + + +
+ + +
+

+ 본 메일은 S&P Data Platform에서 자동 발송되었습니다. +

+
+
+ +