버그 수정: 관리자 권한 DB 불일치 우회 및 다중 엑셀 파일 업로드 기능 구현
This commit is contained in:
+2
-1
@@ -113,11 +113,12 @@ def get_current_user(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
|
|
||||||
def get_admin_user(current_user: User = Depends(get_current_user)):
|
def get_admin_user(current_user: User = Depends(get_current_user)):
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin and current_user.email.lower() not in ADMIN_EMAILS:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
return current_user
|
return current_user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _safe_next(raw: Optional[str]) -> str:
|
def _safe_next(raw: Optional[str]) -> str:
|
||||||
"""클라이언트가 넘긴 next 를 검증. 같은 호스트의 절대경로만 허용.
|
"""클라이언트가 넘긴 next 를 검증. 같은 호스트의 절대경로만 허용.
|
||||||
|
|
||||||
|
|||||||
+39
-24
@@ -420,36 +420,40 @@ def orders_to_excel_response(orders, filename: str, sheet_name: str = "Orders"):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_upload_job(contents: bytes, filename: str, user_id: str, user_data: dict):
|
def run_upload_job(files_data: list[dict], user_id: str, user_data: dict):
|
||||||
current_user = SimpleNamespace(**user_data)
|
current_user = SimpleNamespace(**user_data)
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
|
filenames_str = ", ".join([f["filename"] for f in files_data])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
db_records = []
|
||||||
|
total_rows = 0
|
||||||
|
|
||||||
|
# 1단계: 모든 파일 읽기 및 행 데이터 정규화
|
||||||
|
for f_idx, file_item in enumerate(files_data):
|
||||||
|
contents = file_item["contents"]
|
||||||
|
filename = file_item["filename"]
|
||||||
|
|
||||||
set_upload_state(
|
set_upload_state(
|
||||||
user_id,
|
user_id,
|
||||||
status="reading",
|
status="reading",
|
||||||
message="엑셀 파일을 읽는 중입니다...",
|
message=f"[{f_idx + 1}/{len(files_data)}] 엑셀 파일을 읽는 중입니다 ({filename})...",
|
||||||
)
|
)
|
||||||
|
|
||||||
# read_excel requires xlrd for .xls and openpyxl for .xlsx
|
|
||||||
df = pd.read_excel(io.BytesIO(contents))
|
df = pd.read_excel(io.BytesIO(contents))
|
||||||
|
|
||||||
# Replace NaN with None for SQLAlchemy
|
|
||||||
df = df.where(pd.notnull(df), None)
|
df = df.where(pd.notnull(df), None)
|
||||||
|
|
||||||
column_map = find_template_columns(list(df.columns))
|
column_map = find_template_columns(list(df.columns))
|
||||||
|
|
||||||
total_rows = len(df)
|
file_rows = len(df)
|
||||||
|
total_rows += file_rows
|
||||||
|
|
||||||
set_upload_state(
|
set_upload_state(
|
||||||
user_id,
|
user_id,
|
||||||
status="processing",
|
status="processing",
|
||||||
message=f"총 {total_rows}건의 엑셀 데이터를 정리하는 중입니다...",
|
message=f"[{f_idx + 1}/{len(files_data)}] {filename}의 엑셀 데이터를 정리하는 중...",
|
||||||
total_rows=total_rows,
|
total_rows=total_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
db_records = []
|
|
||||||
batch_size = 1000
|
|
||||||
|
|
||||||
for index, row in df.iterrows():
|
for index, row in df.iterrows():
|
||||||
rlist = list(row)
|
rlist = list(row)
|
||||||
recipient_phone = row_value(rlist, column_map, "recipient_phone")
|
recipient_phone = row_value(rlist, column_map, "recipient_phone")
|
||||||
@@ -483,19 +487,20 @@ def run_upload_job(contents: bytes, filename: str, user_id: str, user_data: dict
|
|||||||
)
|
)
|
||||||
db_records.append(order)
|
db_records.append(order)
|
||||||
|
|
||||||
# Update progress periodically
|
# 주기적으로 데이터 정리 진행률 갱신 (전체 누적 건수 기준)
|
||||||
if index % 1000 == 0 and index > 0:
|
current_processed = len(db_records)
|
||||||
calc_progress = float(index) / total_rows if total_rows else 1
|
if current_processed % 1000 == 0 and current_processed > 0:
|
||||||
|
calc_progress = float(current_processed) / total_rows if total_rows else 1
|
||||||
set_upload_state(
|
set_upload_state(
|
||||||
user_id,
|
user_id,
|
||||||
status="processing",
|
status="processing",
|
||||||
message=f"{index}/{total_rows}건 엑셀 데이터 정리 중...",
|
message=f"전체 {current_processed}건 엑셀 데이터 정리 중...",
|
||||||
upload_progress=calc_progress * 100,
|
upload_progress=calc_progress * 100,
|
||||||
total_rows=total_rows,
|
total_rows=total_rows,
|
||||||
processed_rows=index,
|
processed_rows=current_processed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 2단계: FTS 인덱스 및 DB 저장 단계
|
||||||
set_upload_state(
|
set_upload_state(
|
||||||
user_id,
|
user_id,
|
||||||
status="indexing",
|
status="indexing",
|
||||||
@@ -506,6 +511,7 @@ def run_upload_job(contents: bytes, filename: str, user_id: str, user_data: dict
|
|||||||
processed_rows=total_rows,
|
processed_rows=total_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
for i in range(0, len(db_records), batch_size):
|
for i in range(0, len(db_records), batch_size):
|
||||||
chunk = db_records[i:i + batch_size]
|
chunk = db_records[i:i + batch_size]
|
||||||
db.bulk_save_objects(chunk)
|
db.bulk_save_objects(chunk)
|
||||||
@@ -538,39 +544,48 @@ def run_upload_job(contents: bytes, filename: str, user_id: str, user_data: dict
|
|||||||
current_user,
|
current_user,
|
||||||
"파일 업로드 완료",
|
"파일 업로드 완료",
|
||||||
"success",
|
"success",
|
||||||
{"filename": filename, "uploaded_count": len(db_records)},
|
{"filenames": filenames_str, "uploaded_count": len(db_records)},
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
set_upload_error(user_id, str(e))
|
set_upload_error(user_id, str(e))
|
||||||
log_event(current_user, "파일 업로드 실패", "error", {"filename": filename, "error": str(e)})
|
log_event(current_user, "파일 업로드 실패", "error", {"filenames": filenames_str, "error": str(e)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
set_upload_error(user_id, f"에러 발생: {str(e)}")
|
set_upload_error(user_id, f"에러 발생: {str(e)}")
|
||||||
log_event(current_user, "파일 업로드 실패", "error", {"filename": filename, "error": str(e)})
|
log_event(current_user, "파일 업로드 실패", "error", {"filenames": filenames_str, "error": str(e)})
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload")
|
@router.post("/upload")
|
||||||
def upload_file(
|
def upload_file(
|
||||||
file: UploadFile = File(...),
|
files: list[UploadFile] = File(...),
|
||||||
upload_token: str = Form(...),
|
upload_token: str = Form(...),
|
||||||
current_user = Depends(get_current_user)
|
current_user = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
|
for file in files:
|
||||||
filename = file.filename or ""
|
filename = file.filename or ""
|
||||||
if not filename.endswith(".xls") and not filename.endswith(".xlsx"):
|
if not filename.endswith(".xls") and not filename.endswith(".xlsx"):
|
||||||
raise HTTPException(status_code=400, detail="Only XLS or XLSX files are allowed.")
|
raise HTTPException(status_code=400, detail="XLS 또는 XLSX 파일만 업로드할 수 있습니다.")
|
||||||
|
|
||||||
user_id = f"{current_user.id}:{upload_token}"
|
user_id = f"{current_user.id}:{upload_token}"
|
||||||
|
files_data = []
|
||||||
|
for file in files:
|
||||||
contents = file.file.read()
|
contents = file.file.read()
|
||||||
|
files_data.append({
|
||||||
|
"contents": contents,
|
||||||
|
"filename": file.filename or ""
|
||||||
|
})
|
||||||
|
|
||||||
user_data = {
|
user_data = {
|
||||||
"id": getattr(current_user, "id", ""),
|
"id": getattr(current_user, "id", ""),
|
||||||
"email": getattr(current_user, "email", None),
|
"email": getattr(current_user, "email", None),
|
||||||
"name": getattr(current_user, "name", None),
|
"name": getattr(current_user, "name", None),
|
||||||
}
|
}
|
||||||
|
|
||||||
log_event(current_user, "파일 업로드 시작", "started", {"filename": filename})
|
filenames_str = ", ".join([f["filename"] for f in files_data])
|
||||||
|
log_event(current_user, "파일 업로드 시작", "started", {"filenames": filenames_str})
|
||||||
set_upload_state(
|
set_upload_state(
|
||||||
user_id,
|
user_id,
|
||||||
status="queued",
|
status="queued",
|
||||||
@@ -578,7 +593,7 @@ def upload_file(
|
|||||||
)
|
)
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=run_upload_job,
|
target=run_upload_job,
|
||||||
args=(contents, filename, user_id, user_data),
|
args=(files_data, user_id, user_data),
|
||||||
daemon=True,
|
daemon=True,
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -90,7 +90,7 @@
|
|||||||
<span>(.xls, .xlsx)</span>
|
<span>(.xls, .xlsx)</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<input id="file-upload" type="file" accept=".xls, .xlsx" />
|
<input id="file-upload" type="file" accept=".xls, .xlsx" multiple />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+7
-6
@@ -261,17 +261,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fileUpload.addEventListener('change', async (e) => {
|
fileUpload.addEventListener('change', async (e) => {
|
||||||
const file = e.target.files[0];
|
const files = e.target.files;
|
||||||
if (!file) return;
|
if (!files || files.length === 0) return;
|
||||||
logUiEvent('파일 업로드 파일 선택', {
|
logUiEvent('파일 업로드 파일 선택', {
|
||||||
filename: file.name,
|
count: files.length,
|
||||||
size: file.size,
|
filenames: Array.from(files).map(f => f.name)
|
||||||
type: file.type
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const uploadToken = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append('files', files[i]);
|
||||||
|
}
|
||||||
formData.append('upload_token', uploadToken);
|
formData.append('upload_token', uploadToken);
|
||||||
|
|
||||||
resetUploadProgressModal();
|
resetUploadProgressModal();
|
||||||
|
|||||||
Reference in New Issue
Block a user