feat(bypass): 계정 신청 시 프로젝트명, 예상 호출량, 서비스 IP 필드 추가 (#152) #156

병합
HYOJIN feature/ISSUE-152-account-auth-fields 에서 develop 로 2 commits 를 머지했습니다 2026-04-06 14:27:48 +09:00
17개의 변경된 파일607개의 추가작업 그리고 165개의 파일을 삭제

파일 보기

@ -5,6 +5,7 @@
## [Unreleased] ## [Unreleased]
### 추가 ### 추가
- 계정 신청 시 프로젝트명, 예상 호출량, 서비스 IP 필드 추가 (#152)
- Bypass API 사용자 계정 발급 신청 프로세스 (#126) - Bypass API 사용자 계정 발급 신청 프로세스 (#126)
- Spring Security Basic Auth 인증 (Bypass 데이터 API) - Spring Security Basic Auth 인증 (Bypass 데이터 API)
- 계정 신청/승인/거절 백엔드 API 및 프론트엔드 - 계정 신청/승인/거절 백엔드 API 및 프론트엔드

파일 보기

@ -3,6 +3,7 @@ export interface BypassAccountResponse {
username: string; username: string;
displayName: string; displayName: string;
organization: string | null; organization: string | null;
projectName: string | null;
email: string | null; email: string | null;
phone: string | null; phone: string | null;
status: string; status: string;
@ -11,6 +12,7 @@ export interface BypassAccountResponse {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
plainPassword: string | null; plainPassword: string | null;
serviceIps: string | null;
} }
export interface BypassAccountUpdateRequest { export interface BypassAccountUpdateRequest {
@ -39,6 +41,9 @@ export interface BypassRequestResponse {
accountUsername: string | null; accountUsername: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
projectName: string | null;
expectedCallVolume: string | null;
serviceIps: string | null;
} }
export interface BypassRequestSubmitRequest { export interface BypassRequestSubmitRequest {
@ -48,6 +53,9 @@ export interface BypassRequestSubmitRequest {
email: string; email: string;
phone: string; phone: string;
requestedAccessPeriod: string; requestedAccessPeriod: string;
projectName: string;
expectedCallVolume: string;
serviceIps: string; // JSON string
} }
export interface BypassRequestReviewRequest { export interface BypassRequestReviewRequest {
@ -71,6 +79,13 @@ interface ApiResponse<T> {
data: T; data: T;
} }
export interface ServiceIpDto {
ip: string;
purpose: string;
expectedCallVolume: string;
description: string;
}
// BASE URL // BASE URL
const BASE = '/snp-api/api/bypass-account'; const BASE = '/snp-api/api/bypass-account';
@ -122,6 +137,12 @@ export const bypassAccountApi = {
deleteJson<ApiResponse<void>>(`${BASE}/accounts/${id}`), deleteJson<ApiResponse<void>>(`${BASE}/accounts/${id}`),
resetPassword: (id: number) => resetPassword: (id: number) =>
postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}/reset-password`, {}), postJson<ApiResponse<BypassAccountResponse>>(`${BASE}/accounts/${id}/reset-password`, {}),
getAccountIps: (accountId: number) =>
fetchJson<ApiResponse<ServiceIpDto[]>>(`${BASE}/accounts/${accountId}/ips`),
addAccountIp: (accountId: number, data: ServiceIpDto) =>
postJson<ApiResponse<ServiceIpDto>>(`${BASE}/accounts/${accountId}/ips`, data),
deleteAccountIp: (accountId: number, ipId: number) =>
deleteJson<ApiResponse<void>>(`${BASE}/accounts/${accountId}/ips/${ipId}`),
// Requests // Requests
submitRequest: (data: BypassRequestSubmitRequest) => submitRequest: (data: BypassRequestSubmitRequest) =>

파일 보기

@ -12,18 +12,27 @@ interface FormState {
purpose: string; purpose: string;
email: string; email: string;
requestedAccessPeriod: string; requestedAccessPeriod: string;
projectName: string;
serviceIp: string;
servicePurpose: string;
expectedCallVolume: string;
serviceDescription: string;
} }
interface ErrorState { interface ErrorState {
applicantName?: string; applicantName?: string;
email?: string; email?: string;
requestedAccessPeriod?: string; requestedAccessPeriod?: string;
projectName?: string;
serviceIp?: string;
} }
interface TouchedState { interface TouchedState {
applicantName: boolean; applicantName: boolean;
email: boolean; email: boolean;
requestedAccessPeriod: boolean; requestedAccessPeriod: boolean;
projectName: boolean;
serviceIp: boolean;
} }
type PeriodPreset = '3개월' | '6개월' | '9개월' | '1년'; type PeriodPreset = '3개월' | '6개월' | '9개월' | '1년';
@ -34,12 +43,19 @@ const INITIAL_FORM: FormState = {
purpose: '', purpose: '',
email: '', email: '',
requestedAccessPeriod: '', requestedAccessPeriod: '',
projectName: '',
serviceIp: '',
servicePurpose: 'DEV_PC',
expectedCallVolume: 'LOW',
serviceDescription: '',
}; };
const INITIAL_TOUCHED: TouchedState = { const INITIAL_TOUCHED: TouchedState = {
applicantName: false, applicantName: false,
email: false, email: false,
requestedAccessPeriod: false, requestedAccessPeriod: false,
projectName: false,
serviceIp: false,
}; };
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@ -93,6 +109,14 @@ function validateForm(form: FormState): ErrorState {
errors.requestedAccessPeriod = '사용 기간을 선택해주세요.'; errors.requestedAccessPeriod = '사용 기간을 선택해주세요.';
} }
if (!form.projectName.trim()) {
errors.projectName = '프로젝트/서비스명을 입력해주세요.';
}
if (!form.serviceIp.trim()) {
errors.serviceIp = '서비스 IP를 입력해주세요.';
}
return errors; return errors;
} }
@ -178,7 +202,7 @@ 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, requestedAccessPeriod: true }); setTouched({ applicantName: true, email: true, requestedAccessPeriod: true, projectName: true, serviceIp: true });
const currentErrors = validateForm(form); const currentErrors = validateForm(form);
@ -197,6 +221,12 @@ export default function BypassAccessRequest() {
setSubmitting(true); setSubmitting(true);
try { try {
const serviceIpEntry = form.serviceIp.trim() ? [{
ip: form.serviceIp.trim(),
purpose: form.servicePurpose,
expectedCallVolume: form.expectedCallVolume,
description: form.serviceDescription,
}] : [];
const requestData: BypassRequestSubmitRequest = { const requestData: BypassRequestSubmitRequest = {
applicantName: form.applicantName, applicantName: form.applicantName,
organization: form.organization, organization: form.organization,
@ -204,6 +234,9 @@ export default function BypassAccessRequest() {
email: form.email, email: form.email,
phone: '', phone: '',
requestedAccessPeriod: form.requestedAccessPeriod, requestedAccessPeriod: form.requestedAccessPeriod,
projectName: form.projectName,
expectedCallVolume: form.expectedCallVolume,
serviceIps: JSON.stringify(serviceIpEntry),
}; };
const res = await bypassAccountApi.submitRequest(requestData); const res = await bypassAccountApi.submitRequest(requestData);
setSubmittedId(res.data.id); setSubmittedId(res.data.id);
@ -284,7 +317,7 @@ export default function BypassAccessRequest() {
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="bg-wing-surface rounded-xl shadow-md p-6"> <div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="space-y-5"> <div className="space-y-5">
{/* 신청자명 + 기관 */} {/* Row 1: 신청자명 + 기관 */}
<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-applicantName"> <div id="field-applicantName">
<label className="block text-sm font-medium text-wing-text mb-1"> <label className="block text-sm font-medium text-wing-text mb-1">
@ -318,7 +351,7 @@ export default function BypassAccessRequest() {
</div> </div>
</div> </div>
{/* 이메일 + 사용 기간 */} {/* Row 2: 이메일 + 프로젝트/서비스명 */}
<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">
@ -337,6 +370,26 @@ 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-projectName">
<label className="block text-sm font-medium text-wing-text mb-1">
/ <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.projectName}
onChange={(e) => handleChange('projectName', e.target.value)}
onBlur={() => handleBlur('projectName')}
placeholder="사용할 프로젝트 또는 서비스명"
disabled={submitting}
className={inputClass('projectName')}
/>
{showError('projectName') && (
<p className="mt-1 text-xs text-red-500">{errors.projectName}</p>
)}
</div>
</div>
{/* Row 3: 사용 기간 (full width) */}
<div id="field-requestedAccessPeriod"> <div id="field-requestedAccessPeriod">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium text-wing-text"> <label className="text-sm font-medium text-wing-text">
@ -415,9 +468,68 @@ export default function BypassAccessRequest() {
<p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p> <p className="mt-1 text-xs text-red-500">{errors.requestedAccessPeriod}</p>
)} )}
</div> </div>
{/* Row 4: 서비스 IP (단건) */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
IP <span className="text-red-500">*</span>
</label>
<div className="grid grid-cols-4 gap-2 mb-1 text-[10px] text-wing-muted font-medium">
<span>IP </span>
<span></span>
<span> </span>
<span></span>
</div>
<div id="field-serviceIp" className="grid grid-cols-4 gap-2">
<input
type="text"
value={form.serviceIp}
onChange={(e) => handleChange('serviceIp', e.target.value)}
onBlur={() => handleBlur('serviceIp')}
placeholder="192.168.1.1"
disabled={submitting}
className={inputClass('serviceIp')}
/>
<select
value={form.servicePurpose}
onChange={(e) => handleChange('servicePurpose', e.target.value)}
disabled={submitting}
className="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"
>
<option value="DEV_PC"> PC</option>
<option value="PROD_SERVER"> </option>
<option value="TEST_SERVER"> </option>
<option value="ETC"></option>
</select>
<select
value={form.expectedCallVolume}
onChange={(e) => handleChange('expectedCallVolume', e.target.value)}
disabled={submitting}
className="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"
>
<option value="LOW">100 /</option>
<option value="MEDIUM">1,000 /</option>
<option value="HIGH">10,000 /</option>
<option value="VERY_HIGH">10,000 /</option>
</select>
<input
type="text"
value={form.serviceDescription}
onChange={(e) => handleChange('serviceDescription', e.target.value)}
placeholder="설명 (선택)"
disabled={submitting}
className="px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-card text-wing-text placeholder:text-wing-muted focus:outline-none focus:ring-2 focus:ring-wing-accent/50"
/>
</div>
{showError('serviceIp') && (
<p className="mt-1 text-xs text-red-500">{errors.serviceIp}</p>
)}
<p className="mt-1 text-xs text-wing-muted">
IP가 IP별로 . (1 = 1IP = 1)
</p>
</div> </div>
{/* 사용 목적 */} {/* Row 5: 사용 목적 */}
<div> <div>
<label className="block text-sm font-medium text-wing-text mb-1"> <label className="block text-sm font-medium text-wing-text mb-1">

파일 보기

@ -196,30 +196,14 @@ export default function BypassAccountManagement() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-wing-border bg-wing-card"> <tr className="border-b border-wing-border bg-wing-card">
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
Username <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">Username</th>
</th> <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
</th> <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider">
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-wing-border"> <tbody className="divide-y divide-wing-border">
@ -232,18 +216,6 @@ export default function BypassAccountManagement() {
) : ( ) : (
accounts.map((account) => ( accounts.map((account) => (
<tr key={account.id} className="hover:bg-wing-hover transition-colors"> <tr key={account.id} className="hover:bg-wing-hover transition-colors">
<td className="px-4 py-3 font-mono text-xs text-wing-text">
{account.username}
</td>
<td className="px-4 py-3 font-medium text-wing-text">
{account.displayName}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{account.organization ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{account.email ?? '-'}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span <span
className={[ className={[
@ -254,16 +226,28 @@ export default function BypassAccountManagement() {
{account.status} {account.status}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap"> <td className="px-4 py-3 font-mono text-xs text-wing-text">
{account.accessStartDate && account.accessEndDate {account.username}
? `${account.accessStartDate} ~ ${account.accessEndDate}` </td>
: account.accessStartDate ?? '-'} <td className="px-4 py-3 font-medium text-wing-text">
{account.displayName}
</td> </td>
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap"> <td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
{account.createdAt {account.createdAt
? new Date(account.createdAt).toLocaleDateString('ko-KR') ? new Date(account.createdAt).toLocaleDateString('ko-KR')
: '-'} : '-'}
</td> </td>
<td className="px-4 py-3 text-xs text-wing-muted whitespace-nowrap">
{account.accessStartDate && account.accessEndDate
? `${account.accessStartDate} ~ ${account.accessEndDate}`
: account.accessStartDate ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{account.organization ?? '-'}
</td>
<td className="px-4 py-3 text-xs text-wing-muted">
{account.email ?? '-'}
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<button <button
@ -323,8 +307,8 @@ export default function BypassAccountManagement() {
<p className="text-sm text-wing-muted mb-4 font-mono">{editTarget.username}</p> <p className="text-sm text-wing-muted mb-4 font-mono">{editTarget.username}</p>
{/* 신청자 정보 (읽기 전용) */} {/* 신청자 정보 (읽기 전용) */}
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4"> <div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4 text-xs space-y-1.5">
<div className="grid grid-cols-2 gap-2 text-xs"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<span className="text-wing-muted">: </span> <span className="text-wing-muted">: </span>
<span className="text-wing-text font-medium">{editTarget.displayName}</span> <span className="text-wing-text font-medium">{editTarget.displayName}</span>
@ -333,11 +317,33 @@ export default function BypassAccountManagement() {
<span className="text-wing-muted">: </span> <span className="text-wing-muted">: </span>
<span className="text-wing-text">{editTarget.organization ?? '-'}</span> <span className="text-wing-text">{editTarget.organization ?? '-'}</span>
</div> </div>
</div>
<div> <div>
<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>
{editTarget.serviceIps && (() => {
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
try { ips = JSON.parse(editTarget.serviceIps); } catch { /* empty */ }
if (ips.length === 0) return null;
return (
<div>
<div className="text-wing-muted mb-1"> IP</div>
<div className="space-y-1">
{ips.map((ip, i) => (
<div key={i} className="flex items-center gap-2">
<code className="font-mono text-wing-text">{ip.ip}</code>
<span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
</div> </div>
))}
</div>
</div>
);
})()}
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
@ -355,7 +361,7 @@ export default function BypassAccountManagement() {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-wing-text mb-1"> </label> <label className="block text-sm font-medium text-wing-text mb-1"> </label>
<input <input
type="date" type="date"
value={editForm.accessStartDate} value={editForm.accessStartDate}
@ -364,7 +370,7 @@ export default function BypassAccountManagement() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-wing-text mb-1"> </label> <label className="block text-sm font-medium text-wing-text mb-1"> </label>
<input <input
type="date" type="date"
value={editForm.accessEndDate} value={editForm.accessEndDate}

파일 보기

@ -98,7 +98,7 @@ export default function BypassAccountRequests() {
const handleApproveSubmit = async () => { const handleApproveSubmit = async () => {
if (!approveTarget) return; if (!approveTarget) return;
if (!approveForm.reviewedBy.trim()) { if (!approveForm.reviewedBy.trim()) {
showToast('검토자명을 입력해주세요.', 'error'); showToast('승인자을 입력해주세요.', 'error');
return; return;
} }
setApproveSubmitting(true); setApproveSubmitting(true);
@ -123,7 +123,7 @@ export default function BypassAccountRequests() {
const handleRejectSubmit = async () => { const handleRejectSubmit = async () => {
if (!rejectTarget) return; if (!rejectTarget) return;
if (!rejectForm.reviewedBy.trim()) { if (!rejectForm.reviewedBy.trim()) {
showToast('검토자명을 입력해주세요.', 'error'); showToast('승인자을 입력해주세요.', 'error');
return; return;
} }
setRejectSubmitting(true); setRejectSubmitting(true);
@ -189,14 +189,13 @@ export default function BypassAccountRequests() {
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th> <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th> <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th> <th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"> </th>
<th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th> <th className="text-right px-4 py-3 text-xs font-semibold text-wing-muted uppercase tracking-wider"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-wing-border"> <tbody className="divide-y divide-wing-border">
{requests.length === 0 ? ( {requests.length === 0 ? (
<tr> <tr>
<td colSpan={8} className="px-4 py-12 text-center text-wing-muted text-sm"> <td colSpan={7} className="px-4 py-12 text-center text-wing-muted text-sm">
. .
</td> </td>
</tr> </tr>
@ -230,11 +229,6 @@ export default function BypassAccountRequests() {
<td className="px-4 py-3 text-xs text-wing-muted"> <td className="px-4 py-3 text-xs text-wing-muted">
{req.email ?? '-'} {req.email ?? '-'}
</td> </td>
<td className="px-4 py-3 text-xs text-wing-muted max-w-[200px]">
<span className="truncate block" title={req.purpose ?? ''}>
{req.purpose ?? '-'}
</span>
</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@ -279,8 +273,8 @@ export default function BypassAccountRequests() {
<h3 className="text-lg font-semibold text-wing-text mb-4"> </h3> <h3 className="text-lg font-semibold text-wing-text mb-4"> </h3>
{/* 신청 상세 정보 */} {/* 신청 상세 정보 */}
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4"> <div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4 space-y-2 text-xs">
<div className="grid grid-cols-2 gap-2 text-xs"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<span className="text-wing-muted">: </span> <span className="text-wing-muted">: </span>
<span className="text-wing-text font-medium">{approveTarget.applicantName}</span> <span className="text-wing-text font-medium">{approveTarget.applicantName}</span>
@ -289,19 +283,47 @@ export default function BypassAccountRequests() {
<span className="text-wing-muted">: </span> <span className="text-wing-muted">: </span>
<span className="text-wing-text">{approveTarget.organization ?? '-'}</span> <span className="text-wing-text">{approveTarget.organization ?? '-'}</span>
</div> </div>
</div>
<div> <div>
<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>
{approveTarget.projectName && (
<div>
<span className="text-wing-muted">/: </span>
<span className="text-wing-text">{approveTarget.projectName}</span>
</div> </div>
<div className="mt-2 text-xs"> )}
<span className="text-wing-muted"> : </span> <div>
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span> <span className="text-wing-text">{approveTarget.requestedAccessPeriod ?? '-'}</span>
</div> </div>
{approveTarget.serviceIps && (() => {
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
try { ips = JSON.parse(approveTarget.serviceIps); } catch {}
if (ips.length === 0) return null;
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
return (
<div>
<div className="text-wing-muted mb-1"> IP</div>
<div className="bg-wing-surface rounded-lg border border-wing-border p-2 space-y-1">
{ips.map((ip, i) => (
<div key={i} className="flex items-center gap-2">
<code className="font-mono text-wing-text">{ip.ip}</code>
<span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
</div>
))}
</div>
</div>
);
})()}
{approveTarget.purpose && ( {approveTarget.purpose && (
<div className="mt-2 pt-2 border-t border-wing-border text-xs"> <div>
<span className="text-wing-muted"> : </span> <div className="text-wing-muted mb-1"> </div>
<span className="text-wing-text">{approveTarget.purpose}</span> <div className="text-wing-text bg-wing-surface rounded-lg border border-wing-border p-2 whitespace-pre-wrap">{approveTarget.purpose}</div>
</div> </div>
)} )}
</div> </div>
@ -309,7 +331,7 @@ export default function BypassAccountRequests() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-wing-text mb-1"> <label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -322,7 +344,7 @@ export default function BypassAccountRequests() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-wing-text mb-1"> <label className="block text-sm font-medium text-wing-text mb-1">
</label> </label>
<input <input
type="date" type="date"
@ -333,7 +355,7 @@ export default function BypassAccountRequests() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-wing-text mb-1"> <label className="block text-sm font-medium text-wing-text mb-1">
</label> </label>
<input <input
type="date" type="date"
@ -378,8 +400,8 @@ export default function BypassAccountRequests() {
<h3 className="text-lg font-semibold text-wing-text mb-4"> </h3> <h3 className="text-lg font-semibold text-wing-text mb-4"> </h3>
{/* 신청 상세 정보 */} {/* 신청 상세 정보 */}
<div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4"> <div className="bg-wing-card rounded-lg p-3 border border-wing-border mb-4 space-y-2 text-xs">
<div className="grid grid-cols-2 gap-2 text-xs"> <div className="grid grid-cols-2 gap-2">
<div> <div>
<span className="text-wing-muted">: </span> <span className="text-wing-muted">: </span>
<span className="text-wing-text font-medium">{rejectTarget.applicantName}</span> <span className="text-wing-text font-medium">{rejectTarget.applicantName}</span>
@ -388,19 +410,47 @@ export default function BypassAccountRequests() {
<span className="text-wing-muted">: </span> <span className="text-wing-muted">: </span>
<span className="text-wing-text">{rejectTarget.organization ?? '-'}</span> <span className="text-wing-text">{rejectTarget.organization ?? '-'}</span>
</div> </div>
</div>
<div> <div>
<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>
{rejectTarget.projectName && (
<div>
<span className="text-wing-muted">/: </span>
<span className="text-wing-text">{rejectTarget.projectName}</span>
</div> </div>
<div className="mt-2 text-xs"> )}
<span className="text-wing-muted"> : </span> <div>
<span className="text-wing-muted"> : </span>
<span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span> <span className="text-wing-text">{rejectTarget.requestedAccessPeriod ?? '-'}</span>
</div> </div>
{rejectTarget.serviceIps && (() => {
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
try { ips = JSON.parse(rejectTarget.serviceIps); } catch {}
if (ips.length === 0) return null;
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
return (
<div>
<div className="text-wing-muted mb-1"> IP</div>
<div className="bg-wing-surface rounded-lg border border-wing-border p-2 space-y-1">
{ips.map((ip, i) => (
<div key={i} className="flex items-center gap-2">
<code className="font-mono text-wing-text">{ip.ip}</code>
<span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-card text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
</div>
))}
</div>
</div>
);
})()}
{rejectTarget.purpose && ( {rejectTarget.purpose && (
<div className="mt-2 pt-2 border-t border-wing-border text-xs"> <div>
<span className="text-wing-muted"> : </span> <div className="text-wing-muted mb-1"> </div>
<span className="text-wing-text">{rejectTarget.purpose}</span> <div className="text-wing-text bg-wing-surface rounded-lg border border-wing-border p-2 whitespace-pre-wrap">{rejectTarget.purpose}</div>
</div> </div>
)} )}
</div> </div>
@ -408,7 +458,7 @@ export default function BypassAccountRequests() {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-wing-text mb-1"> <label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-red-500">*</span> <span className="text-red-500">*</span>
</label> </label>
<input <input
type="text" type="text"
@ -477,11 +527,37 @@ 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.projectName ?? '-'}</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>
<div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div> <div className="text-wing-text">{detailTarget.requestedAccessPeriod ?? '-'}</div>
</div> </div>
{detailTarget.serviceIps && (() => {
let ips: {ip: string; purpose: string; expectedCallVolume?: string; description: string}[] = [];
try { ips = JSON.parse(detailTarget.serviceIps); } catch {}
if (ips.length === 0) return null;
const PURPOSE_MAP: Record<string, string> = { DEV_PC: '개발 PC', PROD_SERVER: '운영 서버', TEST_SERVER: '테스트 서버', ETC: '기타' };
const VOLUME_MAP: Record<string, string> = { LOW: '100건 이하/일', MEDIUM: '1,000건 이하/일', HIGH: '10,000건 이하/일', VERY_HIGH: '10,000건 이상/일' };
return (
<div>
<div className="text-xs text-wing-muted mb-1"> IP</div>
<div className="bg-wing-card rounded-lg border border-wing-border p-2 space-y-1">
{ips.map((ip, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<code className="font-mono text-wing-text">{ip.ip}</code>
<span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{PURPOSE_MAP[ip.purpose] ?? ip.purpose}</span>
{ip.expectedCallVolume && <span className="px-1.5 py-0.5 rounded bg-wing-surface text-wing-muted text-[10px]">{VOLUME_MAP[ip.expectedCallVolume] ?? ip.expectedCallVolume}</span>}
{ip.description && <span className="text-wing-muted">{ip.description}</span>}
</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>
<div className="text-wing-text whitespace-pre-wrap bg-wing-card rounded-lg p-3 border border-wing-border max-h-48 overflow-y-auto"> <div className="text-wing-text whitespace-pre-wrap bg-wing-card rounded-lg p-3 border border-wing-border max-h-48 overflow-y-auto">

파일 보기

@ -6,6 +6,7 @@ import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest;
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest; import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
import com.snp.batch.global.dto.bypass.BypassRequestResponse; import com.snp.batch.global.dto.bypass.BypassRequestResponse;
import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest; import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest;
import com.snp.batch.global.dto.bypass.ServiceIpDto;
import com.snp.batch.service.BypassApiAccountService; import com.snp.batch.service.BypassApiAccountService;
import com.snp.batch.service.BypassApiRequestService; import com.snp.batch.service.BypassApiRequestService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;

파일 보기

@ -19,6 +19,7 @@ public class BypassAccountResponse {
private String username; private String username;
private String displayName; private String displayName;
private String organization; private String organization;
private String projectName;
private String email; private String email;
private String phone; private String phone;
private String status; private String status;
@ -27,4 +28,5 @@ public class BypassAccountResponse {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
private String plainPassword; private String plainPassword;
private String serviceIps; // JSON string of registered IPs
} }

파일 보기

@ -19,6 +19,9 @@ public class BypassRequestResponse {
private String email; private String email;
private String phone; private String phone;
private String requestedAccessPeriod; private String requestedAccessPeriod;
private String projectName;
private String expectedCallVolume;
private String serviceIps;
private String status; private String status;
private String reviewedBy; private String reviewedBy;
private LocalDateTime reviewedAt; private LocalDateTime reviewedAt;

파일 보기

@ -16,4 +16,7 @@ public class BypassRequestSubmitRequest {
private String email; private String email;
private String phone; private String phone;
private String requestedAccessPeriod; private String requestedAccessPeriod;
private String projectName;
private String expectedCallVolume;
private String serviceIps; // JSON string: [{"ip":"...","purpose":"...","description":"..."}]
} }

파일 보기

@ -0,0 +1,17 @@
package com.snp.batch.global.dto.bypass;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ServiceIpDto {
private String ip;
private String purpose;
private String description;
private String expectedCallVolume;
}

파일 보기

@ -54,6 +54,9 @@ public class BypassApiAccount {
@Column(name = "organization", length = 200) @Column(name = "organization", length = 200)
private String organization; private String organization;
@Column(name = "project_name", length = 200)
private String projectName;
/** /**
* 이메일 * 이메일
*/ */

파일 보기

@ -64,6 +64,24 @@ public class BypassApiRequest {
@Column(name = "requested_access_period", length = 100) @Column(name = "requested_access_period", length = 100)
private String requestedAccessPeriod; private String requestedAccessPeriod;
/**
* 프로젝트명
*/
@Column(name = "project_name", length = 200)
private String projectName;
/**
* 예상 호출량
*/
@Column(name = "expected_call_volume", length = 50)
private String expectedCallVolume;
/**
* 서비스 IP 목록 (JSON 문자열, : [{"ip":"192.168.1.1","purpose":"DEV_PC","description":"개발용"}])
*/
@Column(name = "service_ips", columnDefinition = "TEXT")
private String serviceIps;
/** /**
* 신청 상태 (PENDING, APPROVED, REJECTED) * 신청 상태 (PENDING, APPROVED, REJECTED)
*/ */

파일 보기

@ -0,0 +1,45 @@
package com.snp.batch.global.model;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "bypass_api_service_ip")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BypassApiServiceIp {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "account_id", nullable = false)
private BypassApiAccount account;
@Column(name = "ip_address", nullable = false, length = 45)
private String ipAddress;
@Column(name = "purpose", nullable = false, length = 50)
@Builder.Default
private String purpose = "ETC";
@Column(name = "description", length = 200)
private String description;
@Column(name = "expected_call_volume", length = 50)
private String expectedCallVolume;
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}

파일 보기

@ -0,0 +1,11 @@
package com.snp.batch.global.repository;
import com.snp.batch.global.model.BypassApiServiceIp;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface BypassApiServiceIpRepository extends JpaRepository<BypassApiServiceIp, Long> {
List<BypassApiServiceIp> findByAccountId(Long accountId);
void deleteByAccountId(Long accountId);
}

파일 보기

@ -1,10 +1,14 @@
package com.snp.batch.service; package com.snp.batch.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.snp.batch.global.dto.bypass.BypassAccountResponse; import com.snp.batch.global.dto.bypass.BypassAccountResponse;
import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest; import com.snp.batch.global.dto.bypass.BypassAccountUpdateRequest;
import com.snp.batch.global.dto.bypass.ServiceIpDto;
import com.snp.batch.global.model.AccountStatus; import com.snp.batch.global.model.AccountStatus;
import com.snp.batch.global.model.BypassApiAccount; import com.snp.batch.global.model.BypassApiAccount;
import com.snp.batch.global.model.BypassApiServiceIp;
import com.snp.batch.global.repository.BypassApiAccountRepository; import com.snp.batch.global.repository.BypassApiAccountRepository;
import com.snp.batch.global.repository.BypassApiServiceIpRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -16,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List;
@Slf4j @Slf4j
@Service @Service
@ -30,10 +35,12 @@ public class BypassApiAccountService {
private static final SecureRandom RANDOM = new SecureRandom(); private static final SecureRandom RANDOM = new SecureRandom();
private final BypassApiAccountRepository accountRepository; private final BypassApiAccountRepository accountRepository;
private final BypassApiServiceIpRepository serviceIpRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
@Transactional @Transactional
public BypassAccountResponse createAccount(String displayName, String organization, public BypassAccountResponse createAccount(String displayName, String organization,
String projectName,
String email, String phone, String email, String phone,
LocalDate accessStartDate, LocalDate accessEndDate) { LocalDate accessStartDate, LocalDate accessEndDate) {
String rawUsername = generateUsername(); String rawUsername = generateUsername();
@ -44,6 +51,7 @@ public class BypassApiAccountService {
.passwordHash(passwordEncoder.encode(rawPassword)) .passwordHash(passwordEncoder.encode(rawPassword))
.displayName(displayName) .displayName(displayName)
.organization(organization) .organization(organization)
.projectName(projectName)
.email(email) .email(email)
.phone(phone) .phone(phone)
.status(AccountStatus.ACTIVE) .status(AccountStatus.ACTIVE)
@ -130,12 +138,68 @@ public class BypassApiAccountService {
return sb.toString(); return sb.toString();
} }
@Transactional(readOnly = true)
public List<ServiceIpDto> getServiceIps(Long accountId) {
findOrThrow(accountId);
return serviceIpRepository.findByAccountId(accountId).stream()
.map(ip -> ServiceIpDto.builder()
.ip(ip.getIpAddress())
.purpose(ip.getPurpose())
.description(ip.getDescription())
.expectedCallVolume(ip.getExpectedCallVolume())
.build())
.toList();
}
@Transactional
public ServiceIpDto addServiceIp(Long accountId, ServiceIpDto dto) {
BypassApiAccount account = findOrThrow(accountId);
BypassApiServiceIp saved = serviceIpRepository.save(BypassApiServiceIp.builder()
.account(account)
.ipAddress(dto.getIp())
.purpose(dto.getPurpose() != null ? dto.getPurpose() : "ETC")
.description(dto.getDescription())
.expectedCallVolume(dto.getExpectedCallVolume())
.build());
return ServiceIpDto.builder()
.ip(saved.getIpAddress())
.purpose(saved.getPurpose())
.description(saved.getDescription())
.expectedCallVolume(saved.getExpectedCallVolume())
.build();
}
@Transactional
public void deleteServiceIp(Long accountId, Long ipId) {
findOrThrow(accountId);
serviceIpRepository.deleteById(ipId);
}
private String getServiceIpsJson(Long accountId) {
List<BypassApiServiceIp> ips = serviceIpRepository.findByAccountId(accountId);
if (ips.isEmpty()) return null;
try {
ObjectMapper mapper = new ObjectMapper();
return mapper.writeValueAsString(ips.stream().map(ip ->
ServiceIpDto.builder()
.ip(ip.getIpAddress())
.purpose(ip.getPurpose())
.description(ip.getDescription())
.expectedCallVolume(ip.getExpectedCallVolume())
.build()
).toList());
} catch (Exception e) {
return null;
}
}
private BypassAccountResponse toResponse(BypassApiAccount account, String plainPassword) { private BypassAccountResponse toResponse(BypassApiAccount account, String plainPassword) {
return BypassAccountResponse.builder() return BypassAccountResponse.builder()
.id(account.getId()) .id(account.getId())
.username(account.getUsername()) .username(account.getUsername())
.displayName(account.getDisplayName()) .displayName(account.getDisplayName())
.organization(account.getOrganization()) .organization(account.getOrganization())
.projectName(account.getProjectName())
.email(account.getEmail()) .email(account.getEmail())
.phone(account.getPhone()) .phone(account.getPhone())
.status(account.getStatus().name()) .status(account.getStatus().name())
@ -144,6 +208,7 @@ public class BypassApiAccountService {
.createdAt(account.getCreatedAt()) .createdAt(account.getCreatedAt())
.updatedAt(account.getUpdatedAt()) .updatedAt(account.getUpdatedAt())
.plainPassword(plainPassword) .plainPassword(plainPassword)
.serviceIps(account.getId() != null ? getServiceIpsJson(account.getId()) : null)
.build(); .build();
} }

파일 보기

@ -1,13 +1,18 @@
package com.snp.batch.service; package com.snp.batch.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.snp.batch.global.dto.bypass.BypassAccountResponse; import com.snp.batch.global.dto.bypass.BypassAccountResponse;
import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest; import com.snp.batch.global.dto.bypass.BypassRequestReviewRequest;
import com.snp.batch.global.dto.bypass.BypassRequestResponse; import com.snp.batch.global.dto.bypass.BypassRequestResponse;
import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest; import com.snp.batch.global.dto.bypass.BypassRequestSubmitRequest;
import com.snp.batch.global.dto.bypass.ServiceIpDto;
import com.snp.batch.global.model.BypassApiAccount;
import com.snp.batch.global.model.BypassApiRequest; import com.snp.batch.global.model.BypassApiRequest;
import com.snp.batch.global.model.BypassApiServiceIp;
import com.snp.batch.global.model.RequestStatus; import com.snp.batch.global.model.RequestStatus;
import com.snp.batch.global.repository.BypassApiAccountRepository; import com.snp.batch.global.repository.BypassApiAccountRepository;
import com.snp.batch.global.repository.BypassApiRequestRepository; import com.snp.batch.global.repository.BypassApiRequestRepository;
import com.snp.batch.global.repository.BypassApiServiceIpRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
@ -17,6 +22,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
@Slf4j @Slf4j
@Service @Service
@ -25,6 +31,7 @@ public class BypassApiRequestService {
private final BypassApiRequestRepository requestRepository; private final BypassApiRequestRepository requestRepository;
private final BypassApiAccountRepository accountRepository; private final BypassApiAccountRepository accountRepository;
private final BypassApiServiceIpRepository serviceIpRepository;
private final BypassApiAccountService accountService; private final BypassApiAccountService accountService;
private final EmailService emailService; private final EmailService emailService;
@ -37,6 +44,9 @@ public class BypassApiRequestService {
.email(request.getEmail()) .email(request.getEmail())
.phone(request.getPhone()) .phone(request.getPhone())
.requestedAccessPeriod(request.getRequestedAccessPeriod()) .requestedAccessPeriod(request.getRequestedAccessPeriod())
.projectName(request.getProjectName())
.expectedCallVolume(request.getExpectedCallVolume())
.serviceIps(request.getServiceIps())
.build(); .build();
BypassApiRequest saved = requestRepository.save(entity); BypassApiRequest saved = requestRepository.save(entity);
log.info("Bypass API 계정 신청 접수: applicant={}", request.getApplicantName()); log.info("Bypass API 계정 신청 접수: applicant={}", request.getApplicantName());
@ -71,6 +81,7 @@ public class BypassApiRequestService {
BypassAccountResponse accountResponse = accountService.createAccount( BypassAccountResponse accountResponse = accountService.createAccount(
request.getApplicantName(), request.getApplicantName(),
request.getOrganization(), request.getOrganization(),
request.getProjectName(),
request.getEmail(), request.getEmail(),
request.getPhone(), request.getPhone(),
review.getAccessStartDate(), review.getAccessStartDate(),
@ -80,9 +91,30 @@ public class BypassApiRequestService {
request.setStatus(RequestStatus.APPROVED); request.setStatus(RequestStatus.APPROVED);
request.setReviewedBy(review.getReviewedBy()); request.setReviewedBy(review.getReviewedBy());
request.setReviewedAt(LocalDateTime.now()); request.setReviewedAt(LocalDateTime.now());
request.setAccount(accountRepository.getReferenceById(accountResponse.getId())); BypassApiAccount accountEntity = accountRepository.getReferenceById(accountResponse.getId());
request.setAccount(accountEntity);
requestRepository.save(request); requestRepository.save(request);
// 서비스 IP 등록
if (request.getServiceIps() != null && !request.getServiceIps().isBlank()) {
try {
ObjectMapper mapper = new ObjectMapper();
List<ServiceIpDto> ips = mapper.readValue(request.getServiceIps(),
mapper.getTypeFactory().constructCollectionType(List.class, ServiceIpDto.class));
for (ServiceIpDto ipDto : ips) {
serviceIpRepository.save(BypassApiServiceIp.builder()
.account(accountEntity)
.ipAddress(ipDto.getIp())
.purpose(ipDto.getPurpose() != null ? ipDto.getPurpose() : "ETC")
.description(ipDto.getDescription())
.expectedCallVolume(ipDto.getExpectedCallVolume())
.build());
}
} catch (Exception e) {
log.warn("서비스 IP 파싱/저장 실패: {}", e.getMessage());
}
}
// 이메일 발송 (비동기) // 이메일 발송 (비동기)
if (request.getEmail() != null && !request.getEmail().isBlank()) { if (request.getEmail() != null && !request.getEmail().isBlank()) {
emailService.sendAccountApprovedEmail( emailService.sendAccountApprovedEmail(
@ -157,6 +189,9 @@ public class BypassApiRequestService {
.email(request.getEmail()) .email(request.getEmail())
.phone(request.getPhone()) .phone(request.getPhone())
.requestedAccessPeriod(request.getRequestedAccessPeriod()) .requestedAccessPeriod(request.getRequestedAccessPeriod())
.projectName(request.getProjectName())
.expectedCallVolume(request.getExpectedCallVolume())
.serviceIps(request.getServiceIps())
.status(request.getStatus().name()) .status(request.getStatus().name())
.reviewedBy(request.getReviewedBy()) .reviewedBy(request.getReviewedBy())
.reviewedAt(request.getReviewedAt()) .reviewedAt(request.getReviewedAt())

파일 보기

@ -36,3 +36,26 @@ CREATE TABLE bypass_api_request (
); );
CREATE INDEX idx_bypass_request_status ON bypass_api_request(status); CREATE INDEX idx_bypass_request_status ON bypass_api_request(status);
-- 서비스 IP 관리 테이블
CREATE TABLE bypass_api_service_ip (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL REFERENCES bypass_api_account(id) ON DELETE CASCADE,
ip_address VARCHAR(45) NOT NULL,
purpose VARCHAR(50) NOT NULL DEFAULT 'ETC',
description VARCHAR(200),
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_bypass_service_ip_account ON bypass_api_service_ip(account_id);
-- bypass_api_request에 컬럼 추가
ALTER TABLE bypass_api_request ADD COLUMN project_name VARCHAR(200);
ALTER TABLE bypass_api_request ADD COLUMN expected_call_volume VARCHAR(50);
ALTER TABLE bypass_api_request ADD COLUMN service_ips TEXT;
-- 서비스 IP에 예상 호출량 컬럼 추가
ALTER TABLE bypass_api_service_ip ADD COLUMN expected_call_volume VARCHAR(50);
-- bypass_api_account에 프로젝트명 컬럼 추가
ALTER TABLE bypass_api_account ADD COLUMN project_name VARCHAR(200);