--- app/(protected)/(basic)/boards/_actions.ts
+++ app/(protected)/(basic)/boards/_actions.ts
... | ... | @@ -23,7 +23,7 @@ |
| 23 | 23 |
} from '@/lib/domain/board-post-form'; |
| 24 | 24 |
import { BOARD_PATHS } from '@/lib/domain/board-post-query';
|
| 25 | 25 |
import {
|
| 26 |
- FILE_MODULE_ID, |
|
| 26 |
+ BOARD_ATTACHMENT_MODULE_ID, |
|
| 27 | 27 |
MAX_ATTACHMENT_BYTES, |
| 28 | 28 |
} from '@/lib/domain/file-module'; |
| 29 | 29 |
|
... | ... | @@ -105,7 +105,7 @@ |
| 105 | 105 |
throw new Error(OVERSIZE_MESSAGE); |
| 106 | 106 |
} |
| 107 | 107 |
|
| 108 |
- return uploadAttachment(file, FILE_MODULE_ID.board); |
|
| 108 |
+ return uploadAttachment(file, BOARD_ATTACHMENT_MODULE_ID); |
|
| 109 | 109 |
} |
| 110 | 110 |
|
| 111 | 111 |
/** 등록·수정이 공유하는 입력 읽기. */ |
--- app/(protected)/(basic)/contents/_actions.ts
+++ app/(protected)/(basic)/contents/_actions.ts
... | ... | @@ -25,7 +25,12 @@ |
| 25 | 25 |
type ContentFormState, |
| 26 | 26 |
} from '@/lib/domain/content-form'; |
| 27 | 27 |
import { contentListPath } from '@/lib/domain/content-query';
|
| 28 |
-import { FILE_MODULE_ID, MAX_ATTACHMENT_BYTES } from '@/lib/domain/file-module';
|
|
| 28 |
+import {
|
|
| 29 |
+ FILE_MODULE, |
|
| 30 |
+ MAX_ATTACHMENT_BYTES, |
|
| 31 |
+ isAllowedFileExtension, |
|
| 32 |
+ type FileModule, |
|
| 33 |
+} from '@/lib/domain/file-module'; |
|
| 29 | 34 |
|
| 30 | 35 |
/** |
| 31 | 36 |
* 콘텐츠관리 Server Action — 세 종류가 같은 파일을 쓴다(폼 구조가 거의 같다). |
... | ... | @@ -69,10 +74,14 @@ |
| 69 | 74 |
})); |
| 70 | 75 |
} |
| 71 | 76 |
|
| 72 |
-/** 새로 고른 파일을 올려 파일 ID를 얻는다. 고르지 않았으면 폼이 들고 있던 기존 ID를 그대로 쓴다. */ |
|
| 77 |
+/** |
|
| 78 |
+ * 새로 고른 파일을 알맞은 저장소에 올려 파일 ID를 얻는다. 고르지 않았으면 폼이 들고 있던 기존 |
|
| 79 |
+ * ID를 그대로 쓴다. 확장자는 백엔드가 사유 없이 실패하기 전에 여기서 먼저 잡는다. |
|
| 80 |
+ */ |
|
| 73 | 81 |
async function resolveFileId( |
| 74 | 82 |
file: File | null, |
| 75 |
- retainedId: string |
|
| 83 |
+ retainedId: string, |
|
| 84 |
+ module: FileModule |
|
| 76 | 85 |
): Promise<string> {
|
| 77 | 86 |
if (!file) {
|
| 78 | 87 |
return retainedId; |
... | ... | @@ -80,7 +89,12 @@ |
| 80 | 89 |
if (file.size > MAX_ATTACHMENT_BYTES) {
|
| 81 | 90 |
throw new Error(OVERSIZE_MESSAGE); |
| 82 | 91 |
} |
| 83 |
- return uploadAttachment(file, FILE_MODULE_ID.content); |
|
| 92 |
+ if (!isAllowedFileExtension(module, file.name)) {
|
|
| 93 |
+ throw new Error( |
|
| 94 |
+ `${module.description}에는 ${module.extensions.join(', ')} 파일만 올릴 수 있습니다.`
|
|
| 95 |
+ ); |
|
| 96 |
+ } |
|
| 97 |
+ return uploadAttachment(file, module.id); |
|
| 84 | 98 |
} |
| 85 | 99 |
|
| 86 | 100 |
function failure(error: unknown, fallback: string): ContentFormState {
|
... | ... | @@ -116,11 +130,13 @@ |
| 116 | 130 |
...validation.values, |
| 117 | 131 |
videoFileId: await resolveFileId( |
| 118 | 132 |
readFiles(formData, 'videoFile')[0] ?? null, |
| 119 |
- validation.values.videoFileId |
|
| 133 |
+ validation.values.videoFileId, |
|
| 134 |
+ FILE_MODULE.vodContent |
|
| 120 | 135 |
), |
| 121 | 136 |
thumbnailFileId: await resolveFileId( |
| 122 | 137 |
readFiles(formData, 'thumbnailFile')[0] ?? null, |
| 123 |
- validation.values.thumbnailFileId |
|
| 138 |
+ validation.values.thumbnailFileId, |
|
| 139 |
+ FILE_MODULE.contentThumbnail |
|
| 124 | 140 |
), |
| 125 | 141 |
}; |
| 126 | 142 |
await createContent( |
... | ... | @@ -166,11 +182,13 @@ |
| 166 | 182 |
...validation.values, |
| 167 | 183 |
videoFileId: await resolveFileId( |
| 168 | 184 |
readFiles(formData, 'videoFile')[0] ?? null, |
| 169 |
- validation.values.videoFileId |
|
| 185 |
+ validation.values.videoFileId, |
|
| 186 |
+ FILE_MODULE.vodContent |
|
| 170 | 187 |
), |
| 171 | 188 |
thumbnailFileId: await resolveFileId( |
| 172 | 189 |
readFiles(formData, 'thumbnailFile')[0] ?? null, |
| 173 |
- validation.values.thumbnailFileId |
|
| 190 |
+ validation.values.thumbnailFileId, |
|
| 191 |
+ FILE_MODULE.contentThumbnail |
|
| 174 | 192 |
), |
| 175 | 193 |
}; |
| 176 | 194 |
await updateContent('shorts', cntntsSn, buildShortsPayload(values));
|
... | ... | @@ -190,7 +208,11 @@ |
| 190 | 208 |
|
| 191 | 209 |
const ids: string[] = []; |
| 192 | 210 |
for (const [index, file] of files.entries()) {
|
| 193 |
- const id = await resolveFileId(file, retained[index] ?? ''); |
|
| 211 |
+ const id = await resolveFileId( |
|
| 212 |
+ file, |
|
| 213 |
+ retained[index] ?? '', |
|
| 214 |
+ FILE_MODULE.toonContent |
|
| 215 |
+ ); |
|
| 194 | 216 |
if (id) {
|
| 195 | 217 |
ids.push(id); |
| 196 | 218 |
} |
... | ... | @@ -210,7 +232,8 @@ |
| 210 | 232 |
cutFileIds = await resolveCutFileIds(formData); |
| 211 | 233 |
thumbnailFileId = await resolveFileId( |
| 212 | 234 |
readFiles(formData, 'thumbnailFile')[0] ?? null, |
| 213 |
- readString(formData, 'thumbnailFileId') |
|
| 235 |
+ readString(formData, 'thumbnailFileId'), |
|
| 236 |
+ FILE_MODULE.contentThumbnail |
|
| 214 | 237 |
); |
| 215 | 238 |
} catch (error) {
|
| 216 | 239 |
return failure(error, UPLOAD_FAILED_MESSAGE); |
... | ... | @@ -262,7 +285,8 @@ |
| 262 | 285 |
cutFileIds = await resolveCutFileIds(formData); |
| 263 | 286 |
thumbnailFileId = await resolveFileId( |
| 264 | 287 |
readFiles(formData, 'thumbnailFile')[0] ?? null, |
| 265 |
- readString(formData, 'thumbnailFileId') |
|
| 288 |
+ readString(formData, 'thumbnailFileId'), |
|
| 289 |
+ FILE_MODULE.contentThumbnail |
|
| 266 | 290 |
); |
| 267 | 291 |
} catch (error) {
|
| 268 | 292 |
return failure(error, UPLOAD_FAILED_MESSAGE); |
--- app/(protected)/(basic)/contents/_components/content-cuts-field.tsx
+++ app/(protected)/(basic)/contents/_components/content-cuts-field.tsx
... | ... | @@ -6,6 +6,7 @@ |
| 6 | 6 |
import { FoxTextButton } from '@fox/core/components/fox-text-button';
|
| 7 | 7 |
import { FoxPlusIcon, FoxTrashIcon } from '@fox/core/icons';
|
| 8 | 8 |
import { MIN_COMIC_CUTS } from '@/lib/domain/content';
|
| 9 |
+import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
|
|
| 9 | 10 |
import styles from './content-form.module.scss'; |
| 10 | 11 |
|
| 11 | 12 |
interface ContentCutsFieldProps {
|
... | ... | @@ -57,7 +58,7 @@ |
| 57 | 58 |
<FoxFileUpload |
| 58 | 59 |
mode="image" |
| 59 | 60 |
name="cutFile" |
| 60 |
- accept="image/jpeg,image/png" |
|
| 61 |
+ accept={fileModuleAccept(FILE_MODULE.toonContent)}
|
|
| 61 | 62 |
selectLabel="파일선택" |
| 62 | 63 |
thumbnailWidth={132}
|
| 63 | 64 |
thumbnailHeight={74}
|
--- app/(protected)/(basic)/contents/comics/_components/comic-form.tsx
+++ app/(protected)/(basic)/contents/comics/_components/comic-form.tsx
... | ... | @@ -15,6 +15,7 @@ |
| 15 | 15 |
TITLE_MAX_LENGTH, |
| 16 | 16 |
} from '@/lib/domain/content-form'; |
| 17 | 17 |
import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
|
| 18 |
+import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
|
|
| 18 | 19 |
import { createComicAction, updateComicAction } from '../../_actions';
|
| 19 | 20 |
import { ContentCutsField } from '../../_components/content-cuts-field';
|
| 20 | 21 |
import { ContentFormActions } from '../../_components/content-form-actions';
|
... | ... | @@ -117,7 +118,7 @@ |
| 117 | 118 |
<FoxFileUpload |
| 118 | 119 |
mode="image" |
| 119 | 120 |
name="thumbnailFile" |
| 120 |
- accept="image/jpeg,image/png" |
|
| 121 |
+ accept={fileModuleAccept(FILE_MODULE.contentThumbnail)}
|
|
| 121 | 122 |
selectLabel="파일선택" |
| 122 | 123 |
description={THUMBNAIL_HELP_TEXT}
|
| 123 | 124 |
thumbnailWidth={213}
|
--- app/(protected)/(basic)/contents/shorts/_components/shorts-form.tsx
+++ app/(protected)/(basic)/contents/shorts/_components/shorts-form.tsx
... | ... | @@ -18,6 +18,7 @@ |
| 18 | 18 |
TITLE_MAX_LENGTH, |
| 19 | 19 |
} from '@/lib/domain/content-form'; |
| 20 | 20 |
import { contentBreadcrumb, contentListPath } from '@/lib/domain/content-query';
|
| 21 |
+import { FILE_MODULE, fileModuleAccept } from '@/lib/domain/file-module';
|
|
| 21 | 22 |
import { createShortsAction, updateShortsAction } from '../../_actions';
|
| 22 | 23 |
import { ContentFormActions } from '../../_components/content-form-actions';
|
| 23 | 24 |
import { ContentFormRow } from '../../_components/content-form-row';
|
... | ... | @@ -119,7 +120,7 @@ |
| 119 | 120 |
<FoxFileUpload |
| 120 | 121 |
mode="default" |
| 121 | 122 |
name="videoFile" |
| 122 |
- accept="video/*" |
|
| 123 |
+ accept={fileModuleAccept(FILE_MODULE.vodContent)}
|
|
| 123 | 124 |
selectLabel="파일선택" |
| 124 | 125 |
placeholder="파일을 선택해 주세요." |
| 125 | 126 |
/> |
... | ... | @@ -134,7 +135,7 @@ |
| 134 | 135 |
<FoxFileUpload |
| 135 | 136 |
mode="image" |
| 136 | 137 |
name="thumbnailFile" |
| 137 |
- accept="image/jpeg,image/png" |
|
| 138 |
+ accept={fileModuleAccept(FILE_MODULE.contentThumbnail)}
|
|
| 138 | 139 |
selectLabel="파일선택" |
| 139 | 140 |
description={THUMBNAIL_HELP_TEXT}
|
| 140 | 141 |
thumbnailWidth={213}
|
--- lib/data/repositories/decoration-item-repository.ts
+++ lib/data/repositories/decoration-item-repository.ts
... | ... | @@ -8,7 +8,7 @@ |
| 8 | 8 |
DecorationItemQuery, |
| 9 | 9 |
DecorationItemSearchField, |
| 10 | 10 |
} from '@/lib/domain/decoration-item-query'; |
| 11 |
-import { FILE_MODULE_ID } from '@/lib/domain/file-module';
|
|
| 11 |
+import { FILE_MODULE } from '@/lib/domain/file-module';
|
|
| 12 | 12 |
|
| 13 | 13 |
/** |
| 14 | 14 |
* 꾸미기 아이템 Repository — 이 도메인을 백엔드에서 "어떻게 읽고 쓰는지"만 안다(엔드포인트· |
... | ... | @@ -348,7 +348,7 @@ |
| 348 | 348 |
multipart.set('file', file);
|
| 349 | 349 |
|
| 350 | 350 |
const result = await backendFetch<string>( |
| 351 |
- `${FILE_UPLOAD_PATH}/${FILE_MODULE_ID.decorationItem}`,
|
|
| 351 |
+ `${FILE_UPLOAD_PATH}/${FILE_MODULE.userItem.id}`,
|
|
| 352 | 352 |
{
|
| 353 | 353 |
method: 'POST', |
| 354 | 354 |
multipart, |
--- lib/domain/file-module.ts
+++ lib/domain/file-module.ts
... | ... | @@ -1,13 +1,88 @@ |
| 1 |
-// TODO: 백엔드가 모듈 목록 조회 API를 만들어 주기로 함(2026-08-19) — 나오면 이 상수를 대체한다. |
|
| 2 |
-// 이슈: 틀린 값이어도 업로드가 실패하지 않는다. 백엔드가 저장 설정을 못 찾으면 기본 저장소로 |
|
| 3 |
-// 폴백해 조용히 다른 곳에 저장한다. |
|
| 4 |
-export const FILE_MODULE_ID = {
|
|
| 5 |
- decorationItem: 'MODULE_USER_ITEM', |
|
| 6 |
- board: 'MODULE_BBS', |
|
| 7 |
- content: 'MODULE_CNTNTS', |
|
| 8 |
-} as const; |
|
| 1 |
+/** |
|
| 2 |
+ * 파일 저장소 모듈 — 업로드 시 `POST /api/v1/common/file/upload/{moduleId}`의 경로 조각이자
|
|
| 3 |
+ * 백엔드 파일 저장 설정(`FileStrgStngVo.strgStngId`)의 키다. |
|
| 4 |
+ * |
|
| 5 |
+ * 값은 백엔드 저장소 설정 표에서 받은 실제 목록이다(사용자 제공, 2026-08-20). |
|
| 6 |
+ * |
|
| 7 |
+ * 이슈: 설정에 없는 값을 보내도 업로드가 실패하지 않는다. 백엔드가 설정을 못 찾으면 기본 |
|
| 8 |
+ * 저장소로 폴백해 조용히 다른 곳에 저장한다(`EgovFileMngUtil.parseFileInf`). 그래서 여기 없는 |
|
| 9 |
+ * 문자열을 손으로 적지 않는다. |
|
| 10 |
+ */ |
|
| 9 | 11 |
|
| 10 |
-export type FileModuleId = (typeof FILE_MODULE_ID)[keyof typeof FILE_MODULE_ID]; |
|
| 12 |
+export type FileModule = {
|
|
| 13 |
+ id: string; |
|
| 14 |
+ /** 저장소 설명 — 어떤 파일이 어디로 가는지 이 파일만 보고 알 수 있게 함께 둔다. */ |
|
| 15 |
+ description: string; |
|
| 16 |
+ /** 백엔드가 허용하는 확장자. 화면의 `accept`와 사전 검증이 같은 값을 본다. */ |
|
| 17 |
+ extensions: readonly string[]; |
|
| 18 |
+}; |
|
| 19 |
+ |
|
| 20 |
+const IMAGE = ['jpg', 'png', 'gif'] as const; |
|
| 21 |
+ |
|
| 22 |
+export const FILE_MODULE = {
|
|
| 23 |
+ /** 콘텐츠(금융쇼츠·4컷금융만화) 대표 이미지. */ |
|
| 24 |
+ contentThumbnail: {
|
|
| 25 |
+ id: 'MODULE_CONTENT_THUMB', |
|
| 26 |
+ description: '콘텐츠 섬네일 이미지 저장소', |
|
| 27 |
+ extensions: IMAGE, |
|
| 28 |
+ }, |
|
| 29 |
+ /** 4컷금융만화의 컷 이미지. */ |
|
| 30 |
+ toonContent: {
|
|
| 31 |
+ id: 'MODULE_TOON_CONTENT', |
|
| 32 |
+ description: '4컷 만화 파일 저장소', |
|
| 33 |
+ extensions: IMAGE, |
|
| 34 |
+ }, |
|
| 35 |
+ /** 금융쇼츠의 영상. */ |
|
| 36 |
+ vodContent: {
|
|
| 37 |
+ id: 'MODULE_VOD_CONTENT', |
|
| 38 |
+ description: '동영상 콘텐츠 저장소', |
|
| 39 |
+ extensions: ['mp4', 'mov', 'avi', 'mkv', 'webm', 'wmv'], |
|
| 40 |
+ }, |
|
| 41 |
+ /** 꾸미기 아이템 썸네일. */ |
|
| 42 |
+ userItem: {
|
|
| 43 |
+ id: 'MODULE_USER_ITEM', |
|
| 44 |
+ description: '사용자 아이템 저장소', |
|
| 45 |
+ extensions: ['jpg', 'png', 'gif', 'svg'], |
|
| 46 |
+ }, |
|
| 47 |
+ menuIcon: {
|
|
| 48 |
+ id: 'MODULE_MENU_ICON', |
|
| 49 |
+ description: '메뉴 아이콘 저장소', |
|
| 50 |
+ extensions: ['jpg', 'png', 'gif', 'svg'], |
|
| 51 |
+ }, |
|
| 52 |
+ pocket: {
|
|
| 53 |
+ id: 'MODULE_POCKET', |
|
| 54 |
+ description: '용돈기입장 사진 저장소', |
|
| 55 |
+ extensions: ['jpg', 'jpeg', 'png', 'gif'], |
|
| 56 |
+ }, |
|
| 57 |
+ studentImage: {
|
|
| 58 |
+ id: 'MODULE_STUDENT_IMAGE', |
|
| 59 |
+ description: '학생 원본 이미지', |
|
| 60 |
+ extensions: IMAGE, |
|
| 61 |
+ }, |
|
| 62 |
+ userProfile: {
|
|
| 63 |
+ id: 'MODULE_USER_PROFILE', |
|
| 64 |
+ description: '회원 프로필 저장소', |
|
| 65 |
+ extensions: IMAGE, |
|
| 66 |
+ }, |
|
| 67 |
+} as const satisfies Record<string, FileModule>; |
|
| 68 |
+ |
|
| 69 |
+// TODO: 게시판(공지사항·FAQ·1:1문의) 첨부용 저장소가 목록에 없다. 지금은 이 값이 설정에 없어 |
|
| 70 |
+// 기본 저장소로 폴백해 저장된다 — 백엔드에 모듈 추가를 요청하고 그 id로 교체한다. |
|
| 71 |
+export const BOARD_ATTACHMENT_MODULE_ID = 'MODULE_BBS'; |
|
| 72 |
+ |
|
| 73 |
+/** 확장자 목록 → `<input accept>` 값. 화면이 백엔드 허용 목록을 직접 베끼지 않게 한다. */ |
|
| 74 |
+export function fileModuleAccept(module: FileModule): string {
|
|
| 75 |
+ return module.extensions.map((extension) => `.${extension}`).join(',');
|
|
| 76 |
+} |
|
| 77 |
+ |
|
| 78 |
+/** 파일명의 확장자가 저장소 허용 목록에 있는지. 백엔드가 사유 없이 실패하기 전에 먼저 잡는다. */ |
|
| 79 |
+export function isAllowedFileExtension( |
|
| 80 |
+ module: FileModule, |
|
| 81 |
+ fileName: string |
|
| 82 |
+): boolean {
|
|
| 83 |
+ const extension = fileName.split('.').pop()?.toLowerCase() ?? '';
|
|
| 84 |
+ return module.extensions.includes(extension); |
|
| 85 |
+} |
|
| 11 | 86 |
|
| 12 | 87 |
/** 첨부파일 상한. 화면(사전 차단)과 Server Action(재검증)이 같은 값을 본다. */ |
| 13 | 88 |
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?