feat: 독립적인 파일 다운로드 페이지 구현

- React 앱과 완전히 독립된 HTML 페이지 생성 (/download)
- URL 파라미터 검증 (key, expired_time)
- 만료 시간 체크 및 적절한 화면 표시
- 사업자번호 입력 및 자동 포맷팅 (xxx-xxxxx-xx)
- 파일 다운로드 기능 (테스트 모드 포함)
- 반응형 디자인 및 모바일 최적화
- 클린 코드 및 모듈 패턴 적용

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jay Sheen
2025-10-21 15:53:35 +09:00
parent 1648a30844
commit 81d977b97d
2 changed files with 587 additions and 0 deletions

519
public/download.html Normal file
View File

@@ -0,0 +1,519 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>나이스가맹점관리자 - 파일 다운로드</title>
<!-- Favicon -->
<link rel="shortcut icon" href="/images/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-icon-180x180.png">
<link rel="icon" type="image/png" sizes="192x192" href="/images/android-icon-192x192.png">
<style>
/* Global Styles */
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
background: #FAFAFA;
overflow: hidden;
font-family: 'Spoqa Han Sans Neo', Pretendard, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
/* Layout */
.container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
/* Card Component */
.card {
width: 460px;
background: #FFFFFF;
box-shadow: 1px 2px 10px rgba(0, 0, 0, 0.10);
border-radius: 8px;
}
.card-header {
padding: 30px;
text-align: center;
}
.card-title {
font-size: 22px;
font-weight: 700;
color: #111111;
line-height: 1.25;
margin: 0;
}
.card-description {
padding: 0 30px;
text-align: center;
font-size: 14px;
line-height: 1.4;
color: #111111;
}
.card-description--bottom-padding {
padding-bottom: 60px;
}
/* Divider */
.divider {
margin: 30px;
height: 1px;
background: #E5E5E5;
}
/* Password Hint Row */
.hint-row {
display: flex;
align-items: center;
padding: 0 30px 20px;
}
.hint-chip {
display: inline-block;
background: #E9F1FB;
color: #000000;
border-radius: 30px;
padding: 4px 16px;
font-size: 14px;
font-weight: 500;
}
.hint-text {
padding-left: 10px;
font-size: 16px;
font-weight: 500;
color: #111111;
}
.hint-highlight {
color: #3E6AFC;
font-weight: 700;
}
/* Input Group */
.input-group {
margin: 0 30px;
position: relative;
}
.input-wrapper {
display: flex;
align-items: center;
border: 1px solid #CCCCCC;
border-radius: 6px;
padding: 0 12px;
height: 44px;
transition: border-color 0.2s;
}
.input-wrapper.error {
border-color: #FF4757;
box-shadow: 0 0 0 1px #FF4757;
}
.input-icon {
width: 16px;
height: 16px;
margin-right: 10px;
flex-shrink: 0;
}
.input-field {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 14px;
color: #111111;
font-family: inherit;
}
.input-field::placeholder {
color: #8C8C8C;
}
/* Error/Success Message */
.message-text {
display: none;
padding: 8px 0;
font-size: 13px;
color: #FF4757;
}
.message-text.show {
display: block;
}
.message-text.success {
color: #28a745;
}
/* Button */
.btn-primary {
display: block;
width: 140px;
margin: 50px auto 30px;
padding: 0;
background: #002555;
color: #FFFFFF;
font-size: 16px;
font-weight: 500;
line-height: 44px;
text-align: center;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
font-family: inherit;
}
.btn-primary:hover:not(:disabled) {
background: #003366;
}
.btn-primary:disabled {
background: #CCCCCC;
cursor: not-allowed;
}
/* Text Emphasis */
.text-danger {
color: #FF4757;
}
/* Responsive */
@media (max-width: 520px) {
.card {
width: calc(100% - 40px);
margin: 0 20px;
}
}
</style>
</head>
<body>
<div class="container">
<!-- Password Form -->
<div class="card" id="passwordForm" style="display: none;">
<div class="card-header">
<h1 class="card-title">다운로드 비밀번호</h1>
</div>
<div class="card-description">
정보보호를 위해 비밀번호 인증 후 파일 다운로드 가능합니다.<br>
비밀번호를 입력해 주세요.
</div>
<div class="divider"></div>
<div class="hint-row">
<span class="hint-chip">비밀번호</span>
<span class="hint-text">
<span class="hint-highlight">사업자번호</span> 입력
</span>
</div>
<div class="input-group">
<div class="input-wrapper" id="passwordInput">
<svg class="input-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16" fill="none">
<path d="M12.5467 6.272C12.2 5.93067 11.7883 5.67467 11.3333 5.52533V4.26667C11.3333 1.90935 9.38333 0 7 0C4.61667 0 2.66667 1.90935 2.66667 4.26667V5.52533C2.21167 5.67467 1.8 5.93067 1.45333 6.272C0.835851 6.87998 0.5 7.68 0.5 8.53333V12.8C0.5 13.6427 0.846667 14.464 1.45333 15.0613C2.06 15.6587 2.89415 16 3.75 16H10.25C11.1059 16 11.94 15.6587 12.5467 15.0613C13.1533 14.464 13.5 13.6427 13.5 12.8V8.53333C13.5 7.68 13.1642 6.87998 12.5467 6.272ZM7 11.7333C6.40415 11.7333 5.91667 11.2534 5.91667 10.6667C5.91667 10.08 6.40415 9.6 7 9.6C7.59585 9.6 8.08333 10.08 8.08333 10.6667C8.08333 11.2534 7.59585 11.7333 7 11.7333ZM9.16667 5.33333H4.83333V4.26667C4.83333 3.09333 5.80833 2.13333 7 2.13333C8.19167 2.13333 9.16667 3.09333 9.16667 4.26667V5.33333Z" fill="#D9D9D9"/>
</svg>
<input
class="input-field"
id="businessNumber"
type="text"
inputmode="numeric"
placeholder="사업자번호를 입력해 주세요"
aria-label="사업자번호 입력"
>
</div>
<div class="message-text" id="errorText"></div>
</div>
<button class="btn-primary" id="submitBtn">확인</button>
</div>
<!-- Expired Message -->
<div class="card" id="expiredMessage" style="display: none;">
<div class="card-header">
<h1 class="card-title">다운로드 기간 만료</h1>
</div>
<div class="card-description card-description--bottom-padding">
요청하신 파일의 <span class="text-danger">다운로드 가능 기간</span>이 만료되었습니다.<br/>
필요시 가맹점관리자 앱에서 파일을 다시 신청해 주세요.
</div>
</div>
<!-- Invalid Parameters Message -->
<div class="card" id="invalidMessage" style="display: none;">
<div class="card-header">
<h1 class="card-title">다운로드 불가</h1>
</div>
<div class="card-description card-description--bottom-padding">
<span class="text-danger">유효하지 않은 다운로드 링크</span>입니다.<br/>
올바른 링크로 다시 접속하거나<br/>
가맹점관리자 앱에서 파일을 다시 신청해 주세요.
</div>
</div>
</div>
<script>
'use strict';
// Configuration
const CONFIG = {
API_ENDPOINT: '/api/download/validate',
MOCK_MODE: true, // Set to false for production
MOCK_FILE_URL: '/pub/example.xlsx',
DATE_FORMAT: 'YYYYMMDDHHmm',
MIN_YEAR: 2020,
MAX_YEAR: 2100,
BUSINESS_NUMBER_LENGTH: 10,
AUTO_CLOSE_DELAY: 3000
};
// Application State
const app = {
urlParams: new URLSearchParams(window.location.search),
elements: {},
init() {
this.cacheElements();
this.bindEvents();
this.validateAndDisplay();
},
cacheElements() {
this.elements = {
passwordForm: document.getElementById('passwordForm'),
expiredMessage: document.getElementById('expiredMessage'),
invalidMessage: document.getElementById('invalidMessage'),
businessNumber: document.getElementById('businessNumber'),
errorText: document.getElementById('errorText'),
passwordInput: document.getElementById('passwordInput'),
submitBtn: document.getElementById('submitBtn')
};
},
bindEvents() {
this.elements.businessNumber.addEventListener('input', (e) => this.handleInput(e));
this.elements.businessNumber.addEventListener('keypress', (e) => this.handleKeyPress(e));
this.elements.submitBtn.addEventListener('click', () => this.handleSubmit());
},
validateAndDisplay() {
const validation = this.validateParameters();
if (!validation.valid) {
console.error('Invalid parameters:', validation.message);
this.showView('invalid');
} else if (this.isExpired()) {
this.showView('expired');
} else {
this.showView('password');
}
},
showView(viewName) {
const views = {
password: this.elements.passwordForm,
expired: this.elements.expiredMessage,
invalid: this.elements.invalidMessage
};
Object.values(views).forEach(view => view.style.display = 'none');
if (views[viewName]) {
views[viewName].style.display = 'block';
}
},
validateParameters() {
const key = this.urlParams.get('key');
const expiredTime = this.urlParams.get('expired_time');
if (!key?.trim()) {
return { valid: false, message: 'Missing key parameter' };
}
if (!expiredTime?.trim()) {
return { valid: false, message: 'Missing expired_time parameter' };
}
if (!/^\d{12}$/.test(expiredTime)) {
return { valid: false, message: 'Invalid expired_time format' };
}
const dateComponents = this.parseDateComponents(expiredTime);
if (!this.isValidDate(dateComponents)) {
return { valid: false, message: 'Invalid date/time values' };
}
return { valid: true };
},
parseDateComponents(dateString) {
return {
year: parseInt(dateString.substring(0, 4)),
month: parseInt(dateString.substring(4, 6)),
day: parseInt(dateString.substring(6, 8)),
hour: parseInt(dateString.substring(8, 10)),
minute: parseInt(dateString.substring(10, 12))
};
},
isValidDate({ year, month, day, hour, minute }) {
return (
year >= CONFIG.MIN_YEAR && year <= CONFIG.MAX_YEAR &&
month >= 1 && month <= 12 &&
day >= 1 && day <= 31 &&
hour >= 0 && hour <= 23 &&
minute >= 0 && minute <= 59
);
},
isExpired() {
const expiredTime = this.urlParams.get('expired_time');
if (!expiredTime) return false;
const { year, month, day, hour, minute } = this.parseDateComponents(expiredTime);
const expirationDate = new Date(year, month - 1, day, hour, minute);
return new Date() > expirationDate;
},
formatBusinessNumber(value) {
const numbers = value.replace(/\D/g, '');
if (numbers.length <= 3) {
return numbers;
}
if (numbers.length <= 8) {
return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
}
return `${numbers.slice(0, 3)}-${numbers.slice(3, 8)}-${numbers.slice(8, 10)}`;
},
handleInput(event) {
const formatted = this.formatBusinessNumber(event.target.value);
event.target.value = formatted;
this.clearError();
},
handleKeyPress(event) {
if (event.key === 'Enter') {
event.preventDefault();
this.handleSubmit();
}
},
async handleSubmit() {
const businessNumber = this.elements.businessNumber.value;
const cleanNumber = businessNumber.replace(/\D/g, '');
if (!this.validateBusinessNumber(cleanNumber)) {
this.showError('올바른 사업자번호를 입력해 주세요.');
return;
}
this.setLoading(true);
try {
const data = await this.validateDownload(cleanNumber);
await this.downloadFile(data.url);
this.showSuccess('파일 다운로드를 시작합니다...');
this.scheduleAutoClose();
} catch (error) {
console.error('Download error:', error);
const message = error.message === 'Invalid password'
? '비밀번호가 일치하지 않습니다.'
: '오류가 발생했습니다. 다시 시도해 주세요.';
this.showError(message);
} finally {
this.setLoading(false);
}
},
validateBusinessNumber(number) {
return number && number.length === CONFIG.BUSINESS_NUMBER_LENGTH;
},
async validateDownload(password) {
if (CONFIG.MOCK_MODE) {
await this.delay(500);
return { url: CONFIG.MOCK_FILE_URL };
}
const response = await fetch(CONFIG.API_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
key: this.urlParams.get('key'),
password
})
});
if (!response.ok) {
throw new Error('Invalid password');
}
return response.json();
},
async downloadFile(url) {
if (!url) {
throw new Error('No download URL provided');
}
const link = document.createElement('a');
link.href = url;
link.download = url.split('/').pop();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
showError(message) {
this.elements.errorText.textContent = message;
this.elements.errorText.classList.add('show');
this.elements.errorText.classList.remove('success');
this.elements.passwordInput.classList.add('error');
},
showSuccess(message) {
this.elements.errorText.textContent = message;
this.elements.errorText.classList.add('show', 'success');
this.elements.passwordInput.classList.remove('error');
},
clearError() {
this.elements.errorText.classList.remove('show');
this.elements.passwordInput.classList.remove('error');
},
setLoading(isLoading) {
this.elements.submitBtn.disabled = isLoading;
},
scheduleAutoClose() {
setTimeout(() => {
if (window.opener) {
window.close();
}
}, CONFIG.AUTO_CLOSE_DELAY);
},
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
};
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => app.init());
</script>
</body>
</html>

68
src/api/download-api.ts Normal file
View File

@@ -0,0 +1,68 @@
import axios from 'axios';
interface DownloadValidationRequest {
key: string;
password: string;
}
interface DownloadValidationResponse {
success: boolean;
downloadUrl?: string;
message?: string;
}
/**
* Validate download password and get download URL
* @param key - UUID key from URL parameter
* @param password - Business registration number (사업자번호)
* @returns Promise with download URL if validation succeeds
*/
export async function validateDownloadPassword(
key: string,
password: string
): Promise<DownloadValidationResponse> {
try {
const response = await axios.post<DownloadValidationResponse>(
'/api/download/validate',
{
key,
password,
} as DownloadValidationRequest
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
return {
success: false,
message: error.response?.data?.message || '비밀번호가 일치하지 않습니다.',
};
}
return {
success: false,
message: '오류가 발생했습니다. 다시 시도해 주세요.',
};
}
}
/**
* Generate secure download link
* @param merchantId - Merchant ID
* @param fileType - Type of file to download
* @returns Promise with download key and URL
*/
export async function generateDownloadLink(
merchantId: string,
fileType: string
): Promise<{ key: string; url: string }> {
const response = await axios.post<{ key: string; url: string }>(
'/api/download/generate',
{
merchantId,
fileType,
}
);
return response.data;
}