File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
/**
* 파일 저장소 모듈 — 업로드 시 `POST /api/v1/common/file/upload/{moduleId}`의 경로 조각이자
* 백엔드 파일 저장 설정(`FileStrgStngVo.strgStngId`)의 키다.
*
* 값은 백엔드 저장소 설정 표(`TB_SYS_FILE_STRG_STNG`)에서 받은 실제 목록이다(사용자 제공, 2026-08-20).
*
* 이슈: 설정에 없는 값을 보내면 500이 난다. 코드상으론 기본 저장소(`FILE_STORAGE`)로 폴백하지만
* (`EgovFileMngUtil.parseFileInf`) dev DB에 그 행이 없어 거기서 터진다(2026-09-14 확인). 그래서
* 여기 없는 문자열을 손으로 적지 않는다.
*/
export type FileModule = {
id: string;
/** 저장소 설명 — 어떤 파일이 어디로 가는지 이 파일만 보고 알 수 있게 함께 둔다. */
description: string;
/** 백엔드가 허용하는 확장자. 화면의 `accept`와 사전 검증이 같은 값을 본다. */
extensions: readonly string[];
};
/**
* 백엔드 `Globals.fileUpload.Extensions`의 사본 — 확장자를 못 받은 저장소의 잠정값으로만 쓴다.
*
* 실제 업로드를 막는 것은 이 목록이 아니라 저장소 설정의 `PRM_FILE_EXTN`이다
* (`EgovFileMngUtil.checkAllowFileExtension`). `EgovMultipartResolver`도 이 목록을 보긴 하지만
* 어긋났을 때 던지는 `SecurityException`이 주석 처리돼 있어 실제로는 로그만 남는다.
* 그래서 저장소가 허용하는 확장자를 이 목록으로 다시 거르면 안 된다 — svg가 그렇게 막혔다.
*/
export const GLOBAL_UPLOAD_EXTENSIONS: readonly string[] = [
'gif', 'jpg', 'jpeg', 'png',
'xls', 'xlsx', 'ppt', 'pptx', 'doc', 'docx', 'hwp', 'pdf',
'mp4', 'mp3', 'txt', 'html', 'htm',
];
const IMAGE = ['jpg', 'png', 'gif'] as const;
export const FILE_MODULE = {
/** 콘텐츠(금융쇼츠·4컷금융만화) 대표 이미지. */
contentThumbnail: {
id: 'MODULE_CONTENT_THUMB',
description: '콘텐츠 섬네일 이미지 저장소',
extensions: IMAGE,
},
/** 4컷금융만화의 컷 이미지. */
toonContent: {
id: 'MODULE_TOON_CONTENT',
description: '4컷 만화 파일 저장소',
extensions: IMAGE,
},
/** 금융쇼츠의 영상. */
vodContent: {
id: 'MODULE_VOD_CONTENT',
description: '동영상 콘텐츠 저장소',
// TODO: 저장소 표는 mov·avi·mkv·webm·wmv도 허용하지만 실제 통과 여부를 확인하지 못해
// mp4로 좁혀 둔 상태다. 확인되면 표대로 되돌린다.
extensions: ['mp4'],
},
/** 꾸미기 아이템 썸네일. */
userItem: {
id: 'MODULE_USER_ITEM',
description: '사용자 아이템 저장소',
extensions: ['jpg', 'png', 'gif', 'svg'],
},
/**
* 가맹몰 이미지 — 대표·추가 묶음·메뉴 이미지 모두 여기로 간다(백엔드 제공 ID, 2026-09-15).
* TODO: 이 저장소의 허용 확장자 목록을 받지 못했다 — 시안 문구(JPG · PNG)대로 두고, 저장소가
* 더 좁으면 백엔드가 사유를 돌려준다.
*/
merchantImage: {
id: 'MODULE_VOC_IMG',
description: '가맹몰 이미지 저장소',
extensions: ['jpg', 'jpeg', 'png'],
},
menuIcon: {
id: 'MODULE_MENU_ICON',
description: '메뉴 아이콘 저장소',
extensions: ['jpg', 'png', 'gif', 'svg'],
},
pocket: {
id: 'MODULE_POCKET',
description: '용돈기입장 사진 저장소',
extensions: ['jpg', 'jpeg', 'png', 'gif'],
},
studentImage: {
id: 'MODULE_STUDENT_IMAGE',
description: '학생 원본 이미지',
extensions: IMAGE,
},
userProfile: {
id: 'MODULE_USER_PROFILE',
description: '회원 프로필 저장소',
extensions: IMAGE,
},
/** 게시판(공지사항·FAQ·1:1문의) 첨부. */
board: {
id: 'MODULE_BBS',
description: '게시글 스토리지',
// TODO: 이 저장소만의 허용 확장자를 받지 못했다. 전역 화이트리스트를 그대로 두어 화면이
// 먼저 막지 않게 한다 — 저장소 목록이 더 좁으면 백엔드가 사유를 돌려준다.
extensions: GLOBAL_UPLOAD_EXTENSIONS,
},
} as const satisfies Record<string, FileModule>;
/** 실제로 통과하는 확장자 — 판단 기준은 저장소 설정 하나뿐이다. */
export function allowedExtensions(module: FileModule): string[] {
return [...module.extensions];
}
/** 확장자 목록 → `<input accept>` 값. 화면이 백엔드 허용 목록을 직접 베끼지 않게 한다. */
export function fileModuleAccept(module: FileModule): string {
return allowedExtensions(module)
.map((extension) => `.${extension}`)
.join(',');
}
/** 파일명의 확장자가 허용 목록에 있는지. 백엔드가 사유 없이 실패하기 전에 먼저 잡는다. */
export function isAllowedFileExtension(
module: FileModule,
fileName: string
): boolean {
const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
return allowedExtensions(module).includes(extension);
}
export function findFileModule(id: string): FileModule | undefined {
return Object.values(FILE_MODULE).find((module) => module.id === id);
}
/**
* Server Action을 지나는 첨부파일의 상한.
*
* 이슈: 미들웨어(`proxy.ts`)를 지나는 요청은 본문이 10MiB에서 잘린다(측정치는 그 파일 주석
* 참조). Server Action은 페이지 URL로 POST되어 반드시 미들웨어를 지나므로 이 한도를 피할 수
* 없다. 더 큰 파일은 미들웨어에서 제외한 스트리밍 라우트(`/contents/upload`)로 보낸다.
*/
export const MAX_ATTACHMENT_BYTES = 1000 * 1024 * 1024;
/**
* 스트리밍 업로드(`/contents/upload`)로 올릴 수 있는 상한 — 백엔드
* `multipartResolver.maxUploadSize`(100,000,000)와 같은 값이다.
*
* 화면이 파일을 고르는 순간 이 값으로 먼저 막는다. 넘겨 보내면 앞단이 사유 없는 413으로
* 끊어 사용자가 무엇이 잘못됐는지 알 수 없다.
*
* 이슈: dev API 앞단 nginx의 `client_max_body_size`가 기본값 1MiB라 실제로는 1MiB만 통과했다
* (2026-08-21 실측: 1,000KB→401 / 1,050KB→413). 인프라를 백엔드와 같은 100m로 올리기로 해
* (사용자 확정) 이 값을 기준으로 둔다. 인프라 반영 전에는 이 상한을 통과해도 413이 온다.
*/
export const MAX_STREAM_UPLOAD_BYTES = 100 * 1000 * 1000;
/** "100MB"처럼 사람이 읽는 표기. 상한과 안내 문구가 어긋나지 않게 한 곳에서 만든다. */
export function formatMaxUploadSize(bytes: number): string {
return `${Math.floor(bytes / (1000 * 1000))}MB`;
}