feat(email): Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140) #147
@ -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]
|
||||
|
||||
|
||||
@ -135,4 +135,6 @@ export const bypassAccountApi = {
|
||||
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/requests/${id}/approve`, data),
|
||||
rejectRequest: (id: number, data: BypassRequestReviewRequest) =>
|
||||
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reject`, data),
|
||||
reopenRequest: (id: number) =>
|
||||
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reopen`, {}),
|
||||
};
|
||||
|
||||
@ -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: (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
),
|
||||
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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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<FormState>(INITIAL_FORM);
|
||||
const [phoneRaw, setPhoneRaw] = useState('');
|
||||
const [phoneDisplay, setPhoneDisplay] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submittedId, setSubmittedId] = useState<number | null>(null);
|
||||
|
||||
@ -139,16 +120,9 @@ export default function BypassAccessRequest() {
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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() {
|
||||
<div className="space-y-6">
|
||||
{/* 헤더 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-wing-text">Bypass API 접근 신청</h1>
|
||||
<h1 className="text-2xl font-bold text-wing-text">S&P API 계정 신청</h1>
|
||||
<p className="mt-1 text-sm text-wing-muted">
|
||||
Bypass API 사용 계정을 신청합니다. 검토 후 계정 정보가 이메일로 발송됩니다.
|
||||
S&P API 계정을 신청합니다. 검토 후 계정 정보가 이메일로 발송됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -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 목록 보기
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@ -346,7 +318,7 @@ export default function BypassAccessRequest() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이메일 + 전화번호 */}
|
||||
{/* 이메일 + 사용 기간 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div id="field-email">
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
@ -365,21 +337,82 @@ export default function BypassAccessRequest() {
|
||||
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
<div id="field-phone">
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
전화번호
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={phoneDisplay}
|
||||
onChange={handlePhoneChange}
|
||||
onBlur={() => handleBlur('phone')}
|
||||
placeholder="010-0000-0000"
|
||||
disabled={submitting}
|
||||
className={inputClass('phone')}
|
||||
/>
|
||||
{showError('phone') && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.phone}</p>
|
||||
<div id="field-requestedAccessPeriod">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-sm font-medium text-wing-text">
|
||||
사용 기간 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleManual}
|
||||
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
|
||||
>
|
||||
<span>직접 선택</span>
|
||||
<span
|
||||
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
|
||||
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform ${
|
||||
periodManual ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{!periodManual ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
disabled={submitting}
|
||||
className={`px-4 py-1.5 text-sm rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
selectedPreset === preset
|
||||
? 'bg-wing-accent text-white border-wing-accent'
|
||||
: 'bg-wing-card text-wing-text border-wing-border hover:border-wing-accent hover:text-wing-accent'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="date"
|
||||
value={periodFrom}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-wing-muted flex-shrink-0">~</span>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="date"
|
||||
value={periodTo}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.requestedAccessPeriod && !periodRangeError && (
|
||||
<p className="mt-2 text-xs text-wing-muted">
|
||||
선택된 기간: <span className="font-medium text-wing-text">{form.requestedAccessPeriod}</span>
|
||||
</p>
|
||||
)}
|
||||
{periodRangeError && (
|
||||
<p className="mt-1 text-xs text-red-500">{periodRangeError}</p>
|
||||
)}
|
||||
{showError('requestedAccessPeriod') && !periodRangeError && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -399,89 +432,6 @@ export default function BypassAccessRequest() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 요청 접근 기간 */}
|
||||
<div id="field-requestedAccessPeriod">
|
||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
||||
요청 접근 기간 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleManual}
|
||||
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
|
||||
>
|
||||
<span>직접 선택</span>
|
||||
<span
|
||||
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
|
||||
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full bg-white shadow transition-transform ${
|
||||
periodManual ? 'translate-x-4' : 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!periodManual ? (
|
||||
/* Preset buttons */
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
disabled={submitting}
|
||||
className={`px-4 py-1.5 text-sm rounded-full border transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
selectedPreset === preset
|
||||
? 'bg-wing-accent text-white border-wing-accent'
|
||||
: 'bg-wing-card text-wing-text border-wing-border hover:border-wing-accent hover:text-wing-accent'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* Manual date range inputs */
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="date"
|
||||
value={periodFrom}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-wing-muted flex-shrink-0">~</span>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="date"
|
||||
value={periodTo}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Period preview */}
|
||||
{form.requestedAccessPeriod && !periodRangeError && (
|
||||
<p className="mt-2 text-xs text-wing-muted">
|
||||
선택된 기간: <span className="font-medium text-wing-text">{form.requestedAccessPeriod}</span>
|
||||
</p>
|
||||
)}
|
||||
{periodRangeError && (
|
||||
<p className="mt-1 text-xs text-red-500">{periodRangeError}</p>
|
||||
)}
|
||||
{showError('requestedAccessPeriod') && !periodRangeError && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
|
||||
@ -337,10 +337,6 @@ export default function BypassAccountManagement() {
|
||||
<span className="text-wing-muted">이메일: </span>
|
||||
<span className="text-wing-text">{editTarget.email ?? '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-wing-muted">전화번호: </span>
|
||||
<span className="text-wing-text">{editTarget.phone ?? '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -293,14 +293,10 @@ export default function BypassAccountRequests() {
|
||||
<span className="text-wing-muted">이메일: </span>
|
||||
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-wing-muted">전화번호: </span>
|
||||
<span className="text-wing-text">{approveTarget.phone ?? '-'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-wing-muted">요청 기간: </span>
|
||||
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="text-wing-muted">요청 기간: </span>
|
||||
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
|
||||
</div>
|
||||
{approveTarget.purpose && (
|
||||
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
|
||||
@ -396,14 +392,10 @@ export default function BypassAccountRequests() {
|
||||
<span className="text-wing-muted">이메일: </span>
|
||||
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-wing-muted">전화번호: </span>
|
||||
<span className="text-wing-text">{rejectTarget.phone ?? '-'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-wing-muted">요청 기간: </span>
|
||||
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-xs">
|
||||
<span className="text-wing-muted">요청 기간: </span>
|
||||
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
|
||||
</div>
|
||||
{rejectTarget.purpose && (
|
||||
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
|
||||
@ -485,10 +477,6 @@ export default function BypassAccountRequests() {
|
||||
<div className="text-xs text-wing-muted mb-0.5">이메일</div>
|
||||
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-wing-muted mb-0.5">전화번호</div>
|
||||
<div className="text-wing-text">{detailTarget.phone ?? '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-wing-muted mb-0.5">요청 접근 기간</div>
|
||||
@ -549,6 +537,25 @@ export default function BypassAccountRequests() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{detailTarget.status === 'REJECTED' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await bypassAccountApi.reopenRequest(detailTarget.id);
|
||||
setDetailTarget(null);
|
||||
showToast('재심사 상태로 변경되었습니다.', 'success');
|
||||
await loadRequests();
|
||||
} catch (err) {
|
||||
showToast('재심사 처리 실패', 'error');
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-amber-500 hover:bg-amber-600 rounded-lg transition-colors"
|
||||
>
|
||||
재심사
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -92,9 +92,9 @@ export default function BypassCatalog() {
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-wing-text">Bypass API 카탈로그</h1>
|
||||
<h1 className="text-2xl font-bold text-wing-text">S&P Global API 카탈로그</h1>
|
||||
<p className="mt-1 text-sm text-wing-muted">
|
||||
등록된 Bypass API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
||||
S&P Global Maritime API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
|
||||
@ -12,13 +12,13 @@ const sections = [
|
||||
menuCount: 6,
|
||||
},
|
||||
{
|
||||
title: 'S&P Bypass',
|
||||
description: 'S&P Bypass API 관리',
|
||||
detail: 'API 등록, 코드 생성 관리, 테스트',
|
||||
title: 'S&P Global API',
|
||||
description: 'S&P Global Maritime API',
|
||||
detail: 'API 카탈로그, API 계정 신청',
|
||||
path: '/bypass-catalog',
|
||||
icon: '🔗',
|
||||
icon: '🌐',
|
||||
iconClass: 'gc-card-icon gc-card-icon-guide',
|
||||
menuCount: 1,
|
||||
menuCount: 5,
|
||||
},
|
||||
{
|
||||
title: 'S&P Risk & Compliance',
|
||||
|
||||
6
pom.xml
6
pom.xml
@ -45,6 +45,12 @@
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Starter Mail -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Boot Starter Batch -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@ -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<ApiResponse<BypassRequestResponse>> reopenRequest(@PathVariable Long id) {
|
||||
return ResponseEntity.ok(ApiResponse.success(requestService.reopenRequest(id)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
84
src/main/java/com/snp/batch/service/EmailService.java
Normal file
84
src/main/java/com/snp/batch/service/EmailService.java
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
63
src/main/resources/templates/email/account-approved.html
Normal file
63
src/main/resources/templates/email/account-approved.html
Normal file
@ -0,0 +1,63 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<div style="max-width:560px; margin:40px auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<div style="background:#0f172a; padding:24px 32px;">
|
||||
<h1 style="color:#ffffff; font-size:18px; margin:0;">S&P Data Platform</h1>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding:32px;">
|
||||
<p style="font-size:15px; color:#1e293b; margin:0 0 16px;">
|
||||
안녕하세요, <strong th:text="${applicantName}">신청자</strong>님.
|
||||
</p>
|
||||
<p style="font-size:14px; color:#475569; margin:0 0 24px; line-height:1.6;">
|
||||
S&P API 계정 발급 신청이 승인되었습니다.<br/>
|
||||
아래 계정 정보로 API를 사용하실 수 있습니다.
|
||||
</p>
|
||||
|
||||
<!-- Credentials box -->
|
||||
<div style="background:#f8fafc; border:1px solid #e2e8f0; border-radius:8px; padding:20px; margin-bottom:24px;">
|
||||
<table style="width:100%; font-size:14px; color:#1e293b;">
|
||||
<tr>
|
||||
<td style="padding:6px 0; color:#64748b; width:100px;">사용자명</td>
|
||||
<td style="padding:6px 0; font-family:monospace; font-weight:bold;" th:text="${username}">bypass_xxxxxxxx</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0; color:#64748b;">비밀번호</td>
|
||||
<td style="padding:6px 0; font-family:monospace; font-weight:bold;" th:text="${password}">xxxxxxxx</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:6px 0; color:#64748b;">유효 기간</td>
|
||||
<td style="padding:6px 0;" th:text="${accessStartDate} + ' ~ ' + ${accessEndDate}">2026-01-01 ~ 2026-12-31</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Warning -->
|
||||
<div style="background:#fef3c7; border:1px solid #fcd34d; border-radius:8px; padding:12px 16px; margin-bottom:24px;">
|
||||
<p style="font-size:12px; color:#92400e; margin:0; line-height:1.5;">
|
||||
이 비밀번호는 이메일로 1회만 안내됩니다. 안전한 곳에 보관해주세요.<br/>
|
||||
비밀번호 분실 시 관리자에게 재설정을 요청하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:13px; color:#475569; margin:0; line-height:1.5;">
|
||||
<a th:href="${swaggerUrl}" style="color:#2563eb; text-decoration:none; font-weight:500;">Swagger UI</a>에서
|
||||
[Authorize] 버튼을 클릭하여 위 계정으로 인증 후 사용할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="background:#f8fafc; padding:16px 32px; border-top:1px solid #e2e8f0;">
|
||||
<p style="font-size:11px; color:#94a3b8; margin:0;">
|
||||
본 메일은 S&P Data Platform에서 자동 발송되었습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
50
src/main/resources/templates/email/request-rejected.html
Normal file
50
src/main/resources/templates/email/request-rejected.html
Normal file
@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f4f4f5; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||
<div style="max-width:560px; margin:40px auto; background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,0.1);">
|
||||
<!-- Header -->
|
||||
<div style="background:#0f172a; padding:24px 32px;">
|
||||
<h1 style="color:#ffffff; font-size:18px; margin:0;">S&P Data Platform</h1>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div style="padding:32px;">
|
||||
<p style="font-size:15px; color:#1e293b; margin:0 0 16px;">
|
||||
안녕하세요, <strong th:text="${applicantName}">신청자</strong>님.
|
||||
</p>
|
||||
<p style="font-size:14px; color:#475569; margin:0 0 24px; line-height:1.6;">
|
||||
S&P API 계정 신청이 거절되었습니다.
|
||||
</p>
|
||||
|
||||
<!-- Reason box -->
|
||||
<div style="background:#fef2f2; border:1px solid #fecaca; border-radius:8px; padding:16px; margin-bottom:24px;">
|
||||
<p style="font-size:12px; color:#991b1b; margin:0 0 4px; font-weight:bold;">거절 사유</p>
|
||||
<p style="font-size:14px; color:#1e293b; margin:0; line-height:1.6; white-space:pre-wrap;" th:text="${rejectReason}">거절 사유 내용</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:13px; color:#475569; margin:0; line-height:1.5;">
|
||||
추가 문의 사항이 있으시면 관리자에게 연락해주세요.<br/>
|
||||
신청서를 수정하여 다시 제출하실 수 있습니다.
|
||||
</p>
|
||||
|
||||
<!-- 신청 바로가기 -->
|
||||
<div style="text-align:center; margin-top:24px;">
|
||||
<a th:href="${requestUrl}"
|
||||
style="display:inline-block; padding:10px 24px; background:#0f172a; color:#ffffff; font-size:13px; font-weight:600; text-decoration:none; border-radius:8px;">
|
||||
API 계정 신청 바로가기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div style="background:#f8fafc; padding:16px 32px; border-top:1px solid #e2e8f0;">
|
||||
<p style="font-size:11px; color:#94a3b8; margin:0;">
|
||||
본 메일은 S&P Data Platform에서 자동 발송되었습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
불러오는 중...
Reference in New Issue
Block a user