대용량 파일 업로드 지원을 위한 청크(Chunked) 업로드 방식 도입

This commit is contained in:
2026-01-04 19:56:29 +09:00
parent 2f1e090a07
commit 24edadf7d7
2 changed files with 95 additions and 28 deletions

View File

@@ -387,6 +387,64 @@ try {
$stmt->execute([$tenant_id, $currentUser['id'], $scenario_type, $step_id, $log_text, $audio_file_path, $consultation_type]);
echo json_encode(['success' => true, 'message' => '기록이 저장되었습니다.']);
} elseif ($action === 'upload_chunk') {
$upload_id = $data['uploadId'] ?? '';
$chunk_index = intval($data['chunkIndex'] ?? 0);
$total_chunks = intval($data['totalChunks'] ?? 1);
$filename = $data['fileName'] ?? 'uploaded_file';
$tenant_id = $data['tenant_id'] ?? null;
$scenario_type = $data['scenario_type'] ?? 'manager';
$step_id = $data['step_id'] ?? null;
if (!$upload_id || !isset($_FILES['file'])) throw new Exception("청크 데이터가 유효하지 않습니다.");
$chunk_dir = __DIR__ . "/../uploads/chunks/" . $upload_id;
if (!file_exists($chunk_dir)) mkdir($chunk_dir, 0777, true);
$chunk_file = $chunk_dir . "/" . $chunk_index;
if (!move_uploaded_file($_FILES['file']['tmp_name'], $chunk_file)) {
throw new Exception("청크 저장 실패");
}
// 모든 청크가 도착했는지 확인
$received_chunks = count(glob($chunk_dir . "/*"));
if ($received_chunks === $total_chunks) {
$upload_dir = __DIR__ . "/../uploads/attachments/" . $tenant_id . "/";
if (!file_exists($upload_dir)) mkdir($upload_dir, 0777, true);
$file_ext = pathinfo($filename, PATHINFO_EXTENSION);
$file_name_only = pathinfo($filename, PATHINFO_FILENAME);
$new_filename = date('Ymd_His') . "_" . uniqid() . "_" . $filename;
$final_path = $upload_dir . $new_filename;
$out = fopen($final_path, "wb");
for ($i = 0; $i < $total_chunks; $i++) {
$chunk_path = $chunk_dir . "/" . $i;
if (!file_exists($chunk_path)) throw new Exception("청크 누락: " . $i);
$in = fopen($chunk_path, "rb");
while ($buff = fread($in, 4096)) {
fwrite($out, $buff);
}
fclose($in);
unlink($chunk_path);
}
fclose($out);
rmdir($chunk_dir);
$save_path = "uploads/attachments/" . $tenant_id . "/" . $new_filename;
$saved_paths = [[
'name' => $filename,
'path' => $save_path,
'size' => filesize($final_path)
]];
$stmt = $pdo->prepare("INSERT INTO sales_tenant_consultations (tenant_id, manager_id, scenario_type, step_id, log_text, attachment_paths, consultation_type) VALUES (?, ?, ?, ?, ?, ?, ?)");
$stmt->execute([$tenant_id, $currentUser['id'], $scenario_type, $step_id, '첨부파일 업로드 (대용량)', json_encode($saved_paths, JSON_UNESCAPED_UNICODE), 'file']);
echo json_encode(['success' => true, 'completed' => true, 'message' => '업로드 완료']);
} else {
echo json_encode(['success' => true, 'completed' => false, 'chunk' => $chunk_index]);
}
} elseif ($action === 'upload_attachments') {
$tenant_id = $data['tenant_id'] ?? null;
if (!checkTenantPermission($pdo, $tenant_id, $currentUser)) throw new Exception("권한이 없습니다.");

View File

@@ -2428,40 +2428,49 @@
const selectedFiles = Array.from(e.target.files);
if (selectedFiles.length === 0) return;
// 클라이언트 사이드 용량 체크 (기본 20MB)
const MAX_SIZE = 20 * 1024 * 1024;
for (const file of selectedFiles) {
if (file.size > MAX_SIZE) {
alert(`파일 '${file.name}'의 용량이 너무 큽니다. (최대 20MB)`);
e.target.value = '';
return;
}
}
setUploading(true);
const formData = new FormData();
formData.append('tenant_id', tenantId);
formData.append('scenario_type', scenarioType);
formData.append('step_id', stepId);
formData.append('consultation_type', 'file');
selectedFiles.forEach(file => formData.append('files[]', file));
try {
const response = await fetch('api/sales_tenants.php?action=upload_attachments', {
method: 'POST',
body: formData
});
if (response.status === 413) {
throw new Error('파일 용량이 서버 허용 범위를 초과했습니다. (413 Content Too Large)');
const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB chunks
for (const file of selectedFiles) {
const uploadId = Date.now().toString(36) + Math.random().toString(36).substring(2);
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const chunk = file.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const formData = new FormData();
formData.append('file', chunk);
formData.append('uploadId', uploadId);
formData.append('chunkIndex', i);
formData.append('totalChunks', totalChunks);
formData.append('fileName', file.name);
formData.append('tenant_id', tenantId);
formData.append('scenario_type', scenarioType);
formData.append('step_id', stepId);
formData.append('consultation_type', 'file');
const response = await fetch('api/sales_tenants.php?action=upload_chunk', {
method: 'POST',
body: formData
});
if (response.status === 413) {
throw new Error(`파일 '${file.name}'이 서버의 단일 요청 제한을 초과했습니다. 관리자에게 Nginx 수정을 요청하거나 더 작은 청크를 사용해야 합니다.`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || '청크 업로드 실패');
}
}
}
const result = await response.json();
if (result.success) loadFiles();
else alert('업로드 실패: ' + result.error);
loadFiles();
alert('모든 파일이 성공적으로 업로드되었습니다.');
} catch (error) {
console.error('Upload error:', error);
alert('업로드 중 오류가 발생했습니다.');
alert('업로드 중 오류가 발생했습니다: ' + error.message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';