File name
Commit message
Commit date
File name
Commit message
Commit date
09-16
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"',
},
});
}