feat(email): Bypass API 계정 이메일 알림 및 거절 후속 조치 (#140)
- 이메일 공통 모듈 (spring-boot-starter-mail, EmailService, Thymeleaf 템플릿) - 승인 시 계정 발급 이메일 / 거절 시 사유 이메일 자동 발송 - 재심사 기능 (REJECTED → PENDING) - UI 텍스트 리레이블링 (S&P Global API) - 신청 폼 전화번호 필드 제거 및 레이아웃 개선
This commit is contained in:
부모
20489558de
커밋
ad18ab9c30
@ -135,4 +135,6 @@ export const bypassAccountApi = {
|
|||||||
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/requests/${id}/approve`, data),
|
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/requests/${id}/approve`, data),
|
||||||
rejectRequest: (id: number, data: BypassRequestReviewRequest) =>
|
rejectRequest: (id: number, data: BypassRequestReviewRequest) =>
|
||||||
postJson<ApiResponse<BypassRequestResponse>>(`${BASE}/requests/${id}/reject`, data),
|
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',
|
id: 'bypass',
|
||||||
label: 'S&P Bypass',
|
label: 'S&P Global API',
|
||||||
shortLabel: 'Bypass',
|
shortLabel: 'S&P Global API',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<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>
|
</svg>
|
||||||
),
|
),
|
||||||
defaultPath: '/bypass-catalog',
|
defaultPath: '/bypass-catalog',
|
||||||
@ -51,7 +51,7 @@ const MENU_STRUCTURE: MenuSection[] = [
|
|||||||
{ id: 'bypass-config', label: 'API 관리', path: '/bypass-config' },
|
{ id: 'bypass-config', label: 'API 관리', path: '/bypass-config' },
|
||||||
{ id: 'bypass-account-requests', label: '계정 신청 관리', path: '/bypass-account-requests' },
|
{ id: 'bypass-account-requests', label: '계정 신청 관리', path: '/bypass-account-requests' },
|
||||||
{ id: 'bypass-account-management', label: '계정 관리', path: '/bypass-account-management' },
|
{ 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;
|
organization: string;
|
||||||
purpose: string;
|
purpose: string;
|
||||||
email: string;
|
email: string;
|
||||||
phone: string;
|
|
||||||
requestedAccessPeriod: string;
|
requestedAccessPeriod: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ErrorState {
|
interface ErrorState {
|
||||||
applicantName?: string;
|
applicantName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
|
||||||
requestedAccessPeriod?: string;
|
requestedAccessPeriod?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TouchedState {
|
interface TouchedState {
|
||||||
applicantName: boolean;
|
applicantName: boolean;
|
||||||
email: boolean;
|
email: boolean;
|
||||||
phone: boolean;
|
|
||||||
requestedAccessPeriod: boolean;
|
requestedAccessPeriod: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,26 +33,16 @@ const INITIAL_FORM: FormState = {
|
|||||||
organization: '',
|
organization: '',
|
||||||
purpose: '',
|
purpose: '',
|
||||||
email: '',
|
email: '',
|
||||||
phone: '',
|
|
||||||
requestedAccessPeriod: '',
|
requestedAccessPeriod: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL_TOUCHED: TouchedState = {
|
const INITIAL_TOUCHED: TouchedState = {
|
||||||
applicantName: false,
|
applicantName: false,
|
||||||
email: false,
|
email: false,
|
||||||
phone: false,
|
|
||||||
requestedAccessPeriod: false,
|
requestedAccessPeriod: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
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 {
|
function toDateString(date: Date): string {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
@ -89,7 +76,7 @@ function calcPresetPeriod(preset: PeriodPreset): string {
|
|||||||
return `${from} ~ ${to}`;
|
return `${from} ~ ${to}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateForm(form: FormState, phoneRaw: string): ErrorState {
|
function validateForm(form: FormState): ErrorState {
|
||||||
const errors: ErrorState = {};
|
const errors: ErrorState = {};
|
||||||
|
|
||||||
if (!form.applicantName.trim()) {
|
if (!form.applicantName.trim()) {
|
||||||
@ -102,12 +89,8 @@ function validateForm(form: FormState, phoneRaw: string): ErrorState {
|
|||||||
errors.email = '올바른 이메일 형식을 입력해주세요.';
|
errors.email = '올바른 이메일 형식을 입력해주세요.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (phoneRaw && !PHONE_DIGITS_REGEX.test(phoneRaw)) {
|
|
||||||
errors.phone = '전화번호는 11자리 숫자로 입력해주세요. (예: 01012345678)';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!form.requestedAccessPeriod.trim()) {
|
if (!form.requestedAccessPeriod.trim()) {
|
||||||
errors.requestedAccessPeriod = '요청 접근 기간을 선택해주세요.';
|
errors.requestedAccessPeriod = '사용 기간을 선택해주세요.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors;
|
return errors;
|
||||||
@ -118,8 +101,6 @@ export default function BypassAccessRequest() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [form, setForm] = useState<FormState>(INITIAL_FORM);
|
const [form, setForm] = useState<FormState>(INITIAL_FORM);
|
||||||
const [phoneRaw, setPhoneRaw] = useState('');
|
|
||||||
const [phoneDisplay, setPhoneDisplay] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submittedId, setSubmittedId] = useState<number | null>(null);
|
const [submittedId, setSubmittedId] = useState<number | null>(null);
|
||||||
|
|
||||||
@ -139,16 +120,9 @@ export default function BypassAccessRequest() {
|
|||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
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) => {
|
const handleBlur = (field: keyof TouchedState) => {
|
||||||
setTouched((prev) => ({ ...prev, [field]: true }));
|
setTouched((prev) => ({ ...prev, [field]: true }));
|
||||||
const currentErrors = validateForm(form, phoneRaw);
|
const currentErrors = validateForm(form);
|
||||||
setErrors(currentErrors);
|
setErrors(currentErrors);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -204,9 +178,9 @@ export default function BypassAccessRequest() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSubmitAttempted(true);
|
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) {
|
if (periodManual && periodRangeError) {
|
||||||
return;
|
return;
|
||||||
@ -228,7 +202,7 @@ export default function BypassAccessRequest() {
|
|||||||
organization: form.organization,
|
organization: form.organization,
|
||||||
purpose: form.purpose,
|
purpose: form.purpose,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
phone: form.phone,
|
phone: '',
|
||||||
requestedAccessPeriod: form.requestedAccessPeriod,
|
requestedAccessPeriod: form.requestedAccessPeriod,
|
||||||
};
|
};
|
||||||
const res = await bypassAccountApi.submitRequest(requestData);
|
const res = await bypassAccountApi.submitRequest(requestData);
|
||||||
@ -258,9 +232,9 @@ export default function BypassAccessRequest() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div>
|
<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">
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
Bypass API 사용 계정을 신청합니다. 검토 후 계정 정보가 이메일로 발송됩니다.
|
S&P API 계정을 신청합니다. 검토 후 계정 정보가 이메일로 발송됩니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -283,8 +257,6 @@ export default function BypassAccessRequest() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setForm(INITIAL_FORM);
|
setForm(INITIAL_FORM);
|
||||||
setPhoneRaw('');
|
|
||||||
setPhoneDisplay('');
|
|
||||||
setErrors({});
|
setErrors({});
|
||||||
setTouched(INITIAL_TOUCHED);
|
setTouched(INITIAL_TOUCHED);
|
||||||
setSubmitAttempted(false);
|
setSubmitAttempted(false);
|
||||||
@ -304,7 +276,7 @@ export default function BypassAccessRequest() {
|
|||||||
onClick={() => navigate('/bypass-catalog')}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -346,7 +318,7 @@ export default function BypassAccessRequest() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 이메일 + 전화번호 */}
|
{/* 이메일 + 사용 기간 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div id="field-email">
|
<div id="field-email">
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<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>
|
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div id="field-phone">
|
<div id="field-requestedAccessPeriod">
|
||||||
<label className="block text-sm font-medium text-wing-text mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
전화번호
|
<label className="text-sm font-medium text-wing-text">
|
||||||
</label>
|
사용 기간 <span className="text-red-500">*</span>
|
||||||
<input
|
</label>
|
||||||
type="text"
|
<button
|
||||||
value={phoneDisplay}
|
type="button"
|
||||||
onChange={handlePhoneChange}
|
onClick={handleToggleManual}
|
||||||
onBlur={() => handleBlur('phone')}
|
className="flex items-center gap-2 text-xs text-wing-muted hover:text-wing-text transition-colors"
|
||||||
placeholder="010-0000-0000"
|
>
|
||||||
disabled={submitting}
|
<span>직접 선택</span>
|
||||||
className={inputClass('phone')}
|
<span
|
||||||
/>
|
className={`relative inline-flex h-4 w-8 items-center rounded-full transition-colors ${
|
||||||
{showError('phone') && (
|
periodManual ? 'bg-wing-accent' : 'bg-wing-border'
|
||||||
<p className="mt-1 text-xs text-red-500">{errors.phone}</p>
|
}`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ -399,89 +432,6 @@ export default function BypassAccessRequest() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<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-muted">이메일: </span>
|
||||||
<span className="text-wing-text">{editTarget.email ?? '-'}</span>
|
<span className="text-wing-text">{editTarget.email ?? '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="text-wing-muted">전화번호: </span>
|
|
||||||
<span className="text-wing-text">{editTarget.phone ?? '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -293,14 +293,10 @@ export default function BypassAccountRequests() {
|
|||||||
<span className="text-wing-muted">이메일: </span>
|
<span className="text-wing-muted">이메일: </span>
|
||||||
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
|
<span className="text-wing-text">{approveTarget.email ?? '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<span className="text-wing-muted">전화번호: </span>
|
<div className="mt-2 text-xs">
|
||||||
<span className="text-wing-text">{approveTarget.phone ?? '-'}</span>
|
<span className="text-wing-muted">요청 기간: </span>
|
||||||
</div>
|
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-wing-muted">요청 기간: </span>
|
|
||||||
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{approveTarget.purpose && (
|
{approveTarget.purpose && (
|
||||||
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
|
<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-muted">이메일: </span>
|
||||||
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
|
<span className="text-wing-text">{rejectTarget.email ?? '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<span className="text-wing-muted">전화번호: </span>
|
<div className="mt-2 text-xs">
|
||||||
<span className="text-wing-text">{rejectTarget.phone ?? '-'}</span>
|
<span className="text-wing-muted">요청 기간: </span>
|
||||||
</div>
|
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
|
||||||
<div className="col-span-2">
|
|
||||||
<span className="text-wing-muted">요청 기간: </span>
|
|
||||||
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{rejectTarget.purpose && (
|
{rejectTarget.purpose && (
|
||||||
<div className="mt-2 pt-2 border-t border-wing-border text-xs">
|
<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-xs text-wing-muted mb-0.5">이메일</div>
|
||||||
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
|
<div className="text-wing-text">{detailTarget.email ?? '-'}</div>
|
||||||
</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>
|
<div>
|
||||||
<div className="text-xs text-wing-muted mb-0.5">요청 접근 기간</div>
|
<div className="text-xs text-wing-muted mb-0.5">요청 접근 기간</div>
|
||||||
@ -549,6 +537,25 @@ export default function BypassAccountRequests() {
|
|||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -92,9 +92,9 @@ export default function BypassCatalog() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<p className="mt-1 text-sm text-wing-muted">
|
||||||
등록된 Bypass API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
S&P Global Maritime API 목록입니다. Swagger UI에서 직접 테스트할 수 있습니다.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@ -12,13 +12,13 @@ const sections = [
|
|||||||
menuCount: 6,
|
menuCount: 6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'S&P Bypass',
|
title: 'S&P Global API',
|
||||||
description: 'S&P Bypass API 관리',
|
description: 'S&P Global Maritime API',
|
||||||
detail: 'API 등록, 코드 생성 관리, 테스트',
|
detail: 'API 카탈로그, API 계정 신청',
|
||||||
path: '/bypass-catalog',
|
path: '/bypass-catalog',
|
||||||
icon: '🔗',
|
icon: '🌐',
|
||||||
iconClass: 'gc-card-icon gc-card-icon-guide',
|
iconClass: 'gc-card-icon gc-card-icon-guide',
|
||||||
menuCount: 1,
|
menuCount: 5,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'S&P Risk & Compliance',
|
title: 'S&P Risk & Compliance',
|
||||||
|
|||||||
6
pom.xml
6
pom.xml
@ -45,6 +45,12 @@
|
|||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Mail -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Spring Boot Starter Batch -->
|
<!-- Spring Boot Starter Batch -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@ -114,4 +114,10 @@ public class BypassAccountController {
|
|||||||
@RequestBody BypassRequestReviewRequest review) {
|
@RequestBody BypassRequestReviewRequest review) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(requestService.rejectRequest(id, 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 BypassApiRequestRepository requestRepository;
|
||||||
private final BypassApiAccountRepository accountRepository;
|
private final BypassApiAccountRepository accountRepository;
|
||||||
private final BypassApiAccountService accountService;
|
private final BypassApiAccountService accountService;
|
||||||
|
private final EmailService emailService;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public BypassRequestResponse submitRequest(BypassRequestSubmitRequest request) {
|
public BypassRequestResponse submitRequest(BypassRequestSubmitRequest request) {
|
||||||
@ -82,6 +83,18 @@ public class BypassApiRequestService {
|
|||||||
request.setAccount(accountRepository.getReferenceById(accountResponse.getId()));
|
request.setAccount(accountRepository.getReferenceById(accountResponse.getId()));
|
||||||
requestRepository.save(request);
|
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={}",
|
log.info("Bypass API 계정 신청 승인: requestId={}, accountUsername={}",
|
||||||
id, accountResponse.getUsername());
|
id, accountResponse.getUsername());
|
||||||
return accountResponse;
|
return accountResponse;
|
||||||
@ -100,10 +113,36 @@ public class BypassApiRequestService {
|
|||||||
request.setRejectReason(review.getRejectReason());
|
request.setRejectReason(review.getRejectReason());
|
||||||
BypassApiRequest saved = requestRepository.save(request);
|
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);
|
log.info("Bypass API 계정 신청 거절: requestId={}", id);
|
||||||
return toResponse(saved);
|
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) {
|
private BypassApiRequest findOrThrow(Long id) {
|
||||||
return requestRepository.findById(id)
|
return requestRepository.findById(id)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("신청을 찾을 수 없습니다: " + 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/
|
prefix: classpath:/templates/
|
||||||
suffix: .html
|
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 Scheduler Configuration - Using JDBC Store for persistence
|
||||||
quartz:
|
quartz:
|
||||||
job-store-type: jdbc # JDBC store for schedule persistence
|
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