import 'server-only'; import { getSessionAccessToken } from '@/lib/auth/dal'; import { getApiBaseUrl } from '@/lib/env'; import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch'; /** * 첨부파일 Repository — 업로드해서 `atchFileId`를 받아오는 것만 담당한다. * * POST /api/v1/common/file/upload/{moduleId} multipart/form-data, 파트 이름 `file` * → data: "생성된 atchFileId" (문자열) * * 게시판 등록/수정은 이 id를 `atchFileId`로 실어 보내 게시물과 파일을 연결한다. * * **파일 메타데이터(파일명·용량) 조회 API가 없다.** 백엔드 파일 컨트롤러에는 업로드·다운로드·삭제· * 이미지·스트림만 있고 "이 atchFileId에 무슨 파일이 붙어 있는가"를 알려 주는 엔드포인트가 없다. * 그래서 시안(A_BOA_008_p)의 `pic_0001.jpg 88KB` 같은 표기는 **이미 저장된 첨부에 대해서는 * 불가능**하고, 화면은 "첨부 있음 + 다운로드 링크"까지만 보여준다. 방금 업로드한 파일은 * 브라우저가 이름·크기를 알고 있어 그 세션 동안만 표시할 수 있다. * * 다운로드는 이 파일이 다루지 않는다 — 파일 응답은 라우트 핸들러가 스트림으로 중계한다 * (`app/(protected)/(basic)/boards/attachments/route.ts`). */ const FILE_UPLOAD_PATH = '/api/v1/common/file/upload'; const FILE_IMAGE_PATH = '/api/v1/common/file/image'; /** 파일 생성은 일반 조회보다 오래 걸릴 수 있어 넉넉히 잡는다. */ const UPLOAD_TIMEOUT_MS = 60_000; /** * 파일 하나를 업로드하고 `atchFileId`를 돌려준다. * * `moduleId`는 백엔드에서 파일 저장 설정(`FileStrgStngVo.strgStngId`)을 가리킨다. 설정에 없는 * 값이면 기본 저장소(`FILE_STORAGE`)로 폴백해 **실패하지 않고 조용히 다른 곳에 저장된다** * (`EgovFileMngUtil.parseFileInf`). 쓸 수 있는 값은 `lib/domain/file-module.ts`가 모아 둔다. */ export async function uploadAttachment( file: File, moduleId: string ): Promise { const accessToken = await getSessionAccessToken(); const multipart = new FormData(); multipart.set('file', file); const result = await backendFetch( `${FILE_UPLOAD_PATH}/${moduleId}`, { method: 'POST', multipart, accessToken: accessToken ?? undefined, cache: 'no-store', timeoutMs: UPLOAD_TIMEOUT_MS, } ); if (!result.ok) { throw new BackendRequestError(result); } // 성공 응답의 data는 atchFileId 문자열 그대로다(ApiResponseVO.success(atchFileId)). if (typeof result.data !== 'string' || result.data.length === 0) { throw new Error('첨부파일 업로드 응답에 파일 식별자가 없습니다.'); } return result.data; } /** * 여러 장을 **한 `atchFileId` 아래** 올린다 — 파일마다 `fileSn`이 1,2,3…으로 붙는다. * 4컷만화처럼 묶음이 하나의 콘텐츠에 매달리는 자리에 쓴다. * * `appendTo`를 주면 그 묶음 뒤에 덧붙이고 같은 id를 돌려준다(백엔드 40f85c4). 백엔드는 원본 파일명과 * 크기가 같은 장을 이미 있는 것으로 보고 건너뛴다 — 같은 파일을 두 번 넣으면 한 장만 남는다. */ export async function uploadAttachments( files: File[], moduleId: string, appendTo?: string ): Promise { const accessToken = await getSessionAccessToken(); const multipart = new FormData(); for (const file of files) { // 파트 이름이 모두 `file`이어야 백엔드가 List로 받는다. multipart.append('file', file); } const result = await backendFetch( `${FILE_UPLOAD_PATH}/list/${moduleId}`, { method: 'POST', multipart, ...(appendTo ? { query: { atchFileId: appendTo } } : {}), accessToken: accessToken ?? undefined, cache: 'no-store', timeoutMs: UPLOAD_TIMEOUT_MS, } ); if (!result.ok) { throw new BackendRequestError(result); } if (typeof result.data !== 'string' || result.data.length === 0) { throw new Error('첨부파일 업로드 응답에 파일 식별자가 없습니다.'); } return result.data; } /** 업로드된 파일의 이미지 URL. 이 GET은 인증이 필요 없어 브라우저가 직접 부른다. */ export function buildFileImageUrl( atchFileId: string | null, fileSn = 1 ): string | null { if (!atchFileId) { return null; } const url = new URL( `${getApiBaseUrl().replace(/\/+$/, '')}${FILE_IMAGE_PATH}` ); url.searchParams.set('atchFileId', atchFileId); url.searchParams.set('fileSn', String(fileSn)); return url.toString(); }