File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
09-17
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`로 실어 보내 게시물과 파일을 연결한다.
*
* 묶음의 파일 목록은 `GET /api/v1/common/file/image/list/{atchFileId}`(백엔드 1c7b0e0)로 받는다 —
* `fileSn`·원본 파일명·크기·확장자가 온다. 게시판 첨부의 `pic_0001.jpg 88KB` 표기도 이걸로 할 수
* 있다(TODO: 게시판 화면은 아직 "첨부 있음 + 다운로드 링크"만 보인다).
*
* 다운로드는 이 파일이 다루지 않는다 — 파일 응답은 라우트 핸들러가 스트림으로 중계한다
* (`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 FILE_IMAGE_LIST_PATH = '/api/v1/common/file/image/list';
/** 묶음 안의 파일 하나 — 백엔드 `FileUploadVo`에서 화면이 쓰는 것만. */
export type AttachmentFile = {
fileSn: number;
name: string;
size: number | null;
extension: string | null;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object';
}
function toAttachmentFile(raw: unknown): AttachmentFile[] {
if (!isRecord(raw)) return [];
const fileSn = Number(raw.fileSn);
if (!Number.isInteger(fileSn) || fileSn < 1) return [];
return [
{
fileSn,
name: typeof raw.orgnlFileNm === 'string' ? raw.orgnlFileNm : '',
size: typeof raw.fileSz === 'number' ? raw.fileSz : null,
extension: typeof raw.fileExtnNm === 'string' ? raw.fileExtnNm : null,
},
];
}
/** 한 `atchFileId`에 붙은 파일 전부, `fileSn` 오름차순. 없는 id면 빈 배열. */
export async function fetchAttachmentFiles(atchFileId: string): Promise<AttachmentFile[]> {
const accessToken = await getSessionAccessToken();
const result = await backendFetch<unknown>(
`${FILE_IMAGE_LIST_PATH}/${encodeURIComponent(atchFileId)}`,
{
method: 'GET',
accessToken: accessToken ?? undefined,
cache: 'no-store',
canHaveNullData: true,
}
);
if (!result.ok) {
throw new BackendRequestError(result);
}
const files = Array.isArray(result.data) ? result.data.flatMap(toAttachmentFile) : [];
return files.sort((a, b) => a.fileSn - b.fileSn);
}
/** 파일 생성은 일반 조회보다 오래 걸릴 수 있어 넉넉히 잡는다. */
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<string> {
const accessToken = await getSessionAccessToken();
const multipart = new FormData();
multipart.set('file', file);
const result = await backendFetch<unknown>(
`${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<string> {
const accessToken = await getSessionAccessToken();
const multipart = new FormData();
for (const file of files) {
// 파트 이름이 모두 `file`이어야 백엔드가 List<MultipartFile>로 받는다.
multipart.append('file', file);
}
const result = await backendFetch<unknown>(
`${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();
}