import { getSessionAccessToken, verifySession } from '@/lib/auth/dal'; import { backendFetchStream } from '@/lib/http/backend-fetch'; /** * 게시판 첨부파일 다운로드 — 백엔드 파일을 브라우저로 중계한다. * * 왜 라우트 핸들러인가: 브라우저는 백엔드를 직접 호출하지 않는다(설계서 §7 BFF 전제). 백엔드 파일 * API는 토큰을 요구하는데 그 토큰은 httpOnly 세션 안에만 있어 브라우저가 꺼낼 수 없다. 학생 회원 * 엑셀 다운로드와 같은 구조다. * * 본문은 파싱하지 않고 업스트림 스트림을 그대로 흘려보낸다(서버 메모리에 파일 전체를 올리지 * 않는다). 파일명·MIME도 백엔드 헤더를 그대로 전달한다. */ const FILE_DOWNLOAD_PATH = '/api/v1/common/file/download'; const DOWNLOAD_FAILED_MESSAGE = '첨부파일을 내려받지 못했습니다. 잠시 후 다시 시도해 주세요.'; const DOWNLOAD_TIMEOUT_MS = 60_000; export async function GET(request: Request) { // 라우트 핸들러는 UI를 거치지 않고 직접 호출될 수 있으므로 여기서 직접 인증을 확인한다. await verifySession(); const url = new URL(request.url); const attachmentId = url.searchParams.get('atchFileId'); if (!attachmentId) { return new Response('첨부파일 식별자가 없습니다.', { status: 400, headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } // 백엔드는 파일 일련번호(fileSn)로 개별 파일을 지목한다. 게시판은 파일 하나만 붙이므로 1이 // 기본이며, 여러 개를 붙이게 되면 호출부가 번호를 넘긴다. const fileSn = url.searchParams.get('fileSn') ?? '1'; const accessToken = await getSessionAccessToken(); const result = await backendFetchStream(FILE_DOWNLOAD_PATH, { query: { atchFileId: attachmentId, fileSn }, accessToken: accessToken ?? undefined, timeoutMs: DOWNLOAD_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') ?? 'application/octet-stream', 'Content-Disposition': upstream.headers.get('content-disposition') ?? 'attachment', }, }); }