File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
import { getSessionAccessToken, verifySession } from '@/lib/auth/dal';
import { backendFetchStream } from '@/lib/http/backend-fetch';
/**
* 학생 회원 목록 엑셀 다운로드 — 백엔드가 만든 xlsx를 브라우저로 중계한다.
*
* 왜 라우트 핸들러인가: 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). 백엔드
* 엑셀 API는 ROLE_ADMIN 토큰을 요구하는데 그 토큰은 httpOnly 세션 안에만 있어 브라우저가 꺼낼 수
* 없다. Server Component는 파일 응답을 만들 수 없고 Server Action은 반환값을 브라우저 메모리에
* 펼쳐야 하므로, 파일 다운로드는 라우트 핸들러가 유일하게 맞는 자리다.
*
* 본문은 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다(서버 메모리에 파일 전체를 올리지
* 않는다). 파일명·MIME도 백엔드 헤더를 그대로 전달한다 — 백엔드가 이미 RFC 5987 형식
* (`filename*=UTF-8''회원목록_YYYYMMDD.xlsx`)으로 올바르게 내려준다.
*
* 응답 캐시 금지는 next.config.ts의 전역 보안 헤더(`Cache-Control: no-store, ...`)가 담당한다.
*/
const STUDENT_MEMBER_EXCEL_PATH = '/api/v1/mngr/user/list/excel';
/**
* 이 기능은 화면의 검색·페이징과 무관하게 **항상 전체 학생 데이터**를 내려받는다(요구사항).
*
* 그런데 백엔드 엑셀 API는 목록 API와 같은 `selectPagination`을 그대로 쓴다 — 즉 페이징이
* 걸리고, 파라미터를 생략하면 기본값(`recordCountPerPage=10`)이 적용돼 **10건만** 담긴 파일이
* 나온다. "전체" 모드가 따로 없으므로 충분히 큰 상한을 명시해 1페이지로 전부 받아온다.
* 상한을 무한대로 둘 수는 없다(백엔드가 전체 행을 메모리에 올려 워크북을 만든다).
*
* 회원 수가 이 상한을 넘으면 파일이 조용히 잘린다 — 그 시점에는 백엔드에 "전체 조회" 모드가
* 필요하다. 상한을 올리는 것은 임시방편일 뿐이다.
*/
const EXCEL_ROW_LIMIT = 100_000;
/** 전체 행을 모아 워크북을 만드는 시간이 있어 일반 조회보다 넉넉히 잡는다. */
const EXCEL_TIMEOUT_MS = 60_000;
const DOWNLOAD_FAILED_MESSAGE =
'엑셀 파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.';
const XLSX_CONTENT_TYPE =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
export async function GET() {
// 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다
// (proxy의 쿠키 존재 확인은 낙관적 필터일 뿐이다 — 설계서 §8).
await verifySession();
const accessToken = await getSessionAccessToken();
const result = await backendFetchStream(STUDENT_MEMBER_EXCEL_PATH, {
// 검색 조건(searchCondition/searchKeyword)을 의도적으로 보내지 않는다 — 화면에서 무엇을
// 검색 중이든 파일에는 전체가 담겨야 한다.
query: { pageIndex: 1, recordCountPerPage: EXCEL_ROW_LIMIT },
accessToken: accessToken ?? undefined,
timeoutMs: EXCEL_TIMEOUT_MS,
});
if (!result.ok) {
// 실패 사유(코드·백엔드 메시지)는 backendFetchStream이 서버 콘솔에 남긴다. 화면에는
// 일반화된 문구만 내보낸다.
return new Response(DOWNLOAD_FAILED_MESSAGE, {
status: 502,
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
const upstream = result.data;
return new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') ?? XLSX_CONTENT_TYPE,
'Content-Disposition':
upstream.headers.get('content-disposition') ??
'attachment; filename="students.xlsx"',
},
});
}