From 864d85484d2d00d03c0c0de6910f28f33a1a197f Mon Sep 17 00:00:00 2001 From: Macbook Date: Sat, 13 Jun 2026 01:44:49 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=80=EC=A6=9D=EA=B2=8C=EC=8B=9C=ED=8C=90,?= =?UTF-8?q?=20=EC=95=95=EC=B6=95=ED=8C=8C=EC=9D=BC,=20=ED=95=9C=EA=B8=80?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=B2=A8=EB=B6=80=EA=B0=80=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VerificationIssueImageController.java | 7 +- .../VerificationUploadExceptionHandler.java | 2 +- .../VerificationIssueImageService.java | 63 ++++++- .../VerificationIssueAttachmentsSection.tsx | 171 ++++++++++++------ frontend/src/styles/verificationBoard.css | 25 +++ .../src/utils/verificationAttachmentFiles.ts | 100 ++++++++++ frontend/src/utils/verificationBoardApi.ts | 2 +- 7 files changed, 297 insertions(+), 73 deletions(-) create mode 100644 frontend/src/utils/verificationAttachmentFiles.ts diff --git a/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java b/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java index dc0c2b9..1464a55 100644 --- a/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java +++ b/backend/src/main/java/com/goldenchart/controller/VerificationIssueImageController.java @@ -47,9 +47,10 @@ public class VerificationIssueImageController { private static String toUserMessage(String code) { if (code == null) return "업로드할 수 없습니다."; return switch (code) { - case "unsupported image type" -> "지원하지 않는 이미지 형식입니다. (jpeg, png, gif, webp, bmp)"; - case "file too large" -> "이미지 크기는 파일당 5MB 이하여야 합니다."; - case "no files", "no valid image files" -> "업로드할 이미지가 없습니다."; + case "unsupported file type", "unsupported image type" -> + "지원하지 않는 파일 형식입니다. (jpeg, png, gif, webp, bmp, zip, hwp, hwpx)"; + case "file too large" -> "첨부 파일 크기는 파일당 5MB 이하여야 합니다."; + case "no files", "no valid image files", "no valid attachment files" -> "업로드할 파일이 없습니다."; default -> code.startsWith("max images exceeded") ? "이슈당 첨부 이미지는 최대 30장까지입니다." : code.startsWith("issue not found") ? "검증 이슈를 찾을 수 없습니다." : code; diff --git a/backend/src/main/java/com/goldenchart/controller/VerificationUploadExceptionHandler.java b/backend/src/main/java/com/goldenchart/controller/VerificationUploadExceptionHandler.java index 1893eb8..dc8967c 100644 --- a/backend/src/main/java/com/goldenchart/controller/VerificationUploadExceptionHandler.java +++ b/backend/src/main/java/com/goldenchart/controller/VerificationUploadExceptionHandler.java @@ -25,6 +25,6 @@ public class VerificationUploadExceptionHandler { public ResponseEntity> handleMultipart(MultipartException e) { log.warn("[VerificationUpload] multipart error: {}", e.getMessage()); return ResponseEntity.badRequest() - .body(Map.of("message", "이미지 파일 형식을 확인할 수 없습니다.")); + .body(Map.of("message", "첨부 파일 형식을 확인할 수 없습니다.")); } } diff --git a/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java b/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java index 406b614..0637501 100644 --- a/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java +++ b/backend/src/main/java/com/goldenchart/service/VerificationIssueImageService.java @@ -25,10 +25,21 @@ import java.util.UUID; @Slf4j public class VerificationIssueImageService { - private static final Set ALLOWED_TYPES = Set.of( + private static final Set ALLOWED_IMAGE_TYPES = Set.of( "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp" ); + private static final Set ALLOWED_DOCUMENT_TYPES = Set.of( + "application/zip", + "application/x-zip-compressed", + "application/x-hwp", + "application/haansoft-hwp", + "application/vnd.hancom.hwp", + "application/hwp+zip", + "application/vnd.hancom.hwpx", + "application/x-hwpx" + ); + private final GcVerificationIssueImageRepository imageRepository; private final GcVerificationIssueRepository issueRepository; private final Path uploadRoot; @@ -100,7 +111,7 @@ public class VerificationIssueImageService { } if (saved.isEmpty()) { - throw new IllegalArgumentException("no valid image files"); + throw new IllegalArgumentException("no valid attachment files"); } return saved; } @@ -155,14 +166,19 @@ public class VerificationIssueImageService { } private void validateFile(long sizeBytes, String contentType) { - if (contentType == null) { - throw new IllegalArgumentException("unsupported image type"); + if (contentType == null || !isAllowedContentType(contentType)) { + throw new IllegalArgumentException("unsupported file type"); } if (sizeBytes > maxFileSizeBytes) { throw new IllegalArgumentException("file too large"); } } + private static boolean isAllowedContentType(String contentType) { + return ALLOWED_IMAGE_TYPES.contains(contentType) + || ALLOWED_DOCUMENT_TYPES.contains(contentType); + } + private String resolveContentType(MultipartFile file, byte[] data) { String type = file.getContentType(); if (type != null && !type.isBlank()) { @@ -173,12 +189,15 @@ public class VerificationIssueImageService { if ("image/x-png".equals(type)) { return "image/png"; } - if (ALLOWED_TYPES.contains(type)) { + if (ALLOWED_IMAGE_TYPES.contains(type)) { return type; } - // application/octet-stream 등 — 매직 바이트로 판별 + if (ALLOWED_DOCUMENT_TYPES.contains(type)) { + return type; + } + // application/octet-stream 등 — 매직 바이트·확장자로 판별 if ("application/octet-stream".equals(type) || type.startsWith("image/")) { - String sniffed = sniffImageType(data); + String sniffed = sniffFileType(data, file.getOriginalFilename()); if (sniffed != null) return sniffed; } } @@ -191,9 +210,35 @@ public class VerificationIssueImageService { if (lower.endsWith(".gif")) return "image/gif"; if (lower.endsWith(".webp")) return "image/webp"; if (lower.endsWith(".bmp")) return "image/bmp"; + if (lower.endsWith(".zip")) return "application/zip"; + if (lower.endsWith(".hwp")) return "application/x-hwp"; + if (lower.endsWith(".hwpx")) return "application/vnd.hancom.hwpx"; } - return sniffImageType(data); + return sniffFileType(data, file.getOriginalFilename()); + } + + private String sniffFileType(byte[] header, String fileName) { + String image = sniffImageType(header); + if (image != null) return image; + + if (header != null && header.length >= 4 + && header[0] == 0x50 && header[1] == 0x4B + && (header[2] == 0x03 || header[2] == 0x05 || header[2] == 0x07)) { + if (fileName != null && fileName.toLowerCase().endsWith(".hwpx")) { + return "application/vnd.hancom.hwpx"; + } + return "application/zip"; + } + + if (header != null && header.length >= 8 + && header[0] == (byte) 0xD0 && header[1] == (byte) 0xCF + && header[2] == 0x11 && header[3] == (byte) 0xE0) { + if (fileName != null && fileName.toLowerCase().endsWith(".hwp")) { + return "application/x-hwp"; + } + } + return null; } private String sniffImageType(byte[] header) { @@ -230,7 +275,7 @@ public class VerificationIssueImageService { } private String sanitizeFileName(String name) { - if (name == null || name.isBlank()) return "image.png"; + if (name == null || name.isBlank()) return "attachment.bin"; String base = Paths.get(name).getFileName().toString(); return base.replaceAll("[^a-zA-Z0-9._\\-가-힣]", "_"); } diff --git a/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx b/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx index dea7c12..dd60f47 100644 --- a/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx +++ b/frontend/src/components/verificationBoard/VerificationIssueAttachmentsSection.tsx @@ -1,6 +1,6 @@ /** - * 검증 이슈 첨부 이미지 — 화면 캡처·파일 선택·드래그·클립보드 붙여넣기 - * issueId 있음: 서버 즉시 업로드 / 없음: draft 로컬 보관(등록 시 저장 후 업로드) + * 검증 이슈 첨부 — 화면 캡처·파일 선택·드래그·클립보드 붙여넣기 + * 이미지 + zip·hwp·hwpx */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ScreenRegionCaptureOverlay from '../ScreenRegionCaptureOverlay'; @@ -14,21 +14,37 @@ import { import { filesFromClipboardRead, filesFromDataTransfer, - pickImageFiles, - prepareImagesForUpload, } from '../../utils/clipboardImageFiles'; +import { + ATTACHMENT_TYPE_HINT, + VERIFICATION_ATTACHMENT_ACCEPT, + isImageAttachment, + pickAttachmentFiles, + prepareAttachmentsForUpload, +} from '../../utils/verificationAttachmentFiles'; function uploadErrorMessage(e: unknown): string { if (e instanceof Error) { - if (e.message === 'IMAGE_TOO_LARGE') { - return '이미지가 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.'; + if (e.message === 'IMAGE_TOO_LARGE' || e.message === 'FILE_TOO_LARGE') { + return '파일이 너무 큽니다. 5MB 이하로 줄인 뒤 다시 시도하세요.'; } if (e.message === 'IMAGE_READ_FAILED') { return '이미지를 읽을 수 없습니다.'; } if (e.message) return e.message; } - return '이미지 업로드에 실패했습니다.'; + return '첨부 파일 업로드에 실패했습니다.'; +} + +function fileKindLabel(fileName?: string | null, contentType?: string | null): string { + const lower = (fileName ?? '').toLowerCase(); + if (lower.endsWith('.zip')) return 'ZIP'; + if (lower.endsWith('.hwp')) return 'HWP'; + if (lower.endsWith('.hwpx')) return 'HWPX'; + const mime = (contentType ?? '').toLowerCase(); + if (mime.includes('zip')) return 'ZIP'; + if (mime.includes('hwp')) return 'HWP'; + return 'FILE'; } interface Props { @@ -91,17 +107,24 @@ const VerificationIssueAttachmentsSection: React.FC = ({ }, [issueId, reload]); const draftPreviews = useMemo( - () => draftFiles.map((file, index) => ({ - key: `${file.name}-${file.size}-${file.lastModified}-${index}`, - index, - fileName: file.name, - src: URL.createObjectURL(file), - })), + () => draftFiles.map((file, index) => { + const isImage = isImageAttachment(file.type, file.name); + return { + key: `${file.name}-${file.size}-${file.lastModified}-${index}`, + index, + fileName: file.name, + isImage, + src: isImage ? URL.createObjectURL(file) : undefined, + kind: fileKindLabel(file.name, file.type), + }; + }), [draftFiles], ); useEffect(() => () => { - draftPreviews.forEach(p => URL.revokeObjectURL(p.src)); + draftPreviews.forEach(p => { + if (p.src) URL.revokeObjectURL(p.src); + }); }, [draftPreviews]); useEffect(() => { @@ -115,14 +138,14 @@ const VerificationIssueAttachmentsSection: React.FC = ({ }, []); const addDraftFiles = useCallback(async (files: File[]) => { - const imgs = pickImageFiles(files); - if (imgs.length === 0) { - window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.'); + const picked = pickAttachmentFiles(files); + if (picked.length === 0) { + window.alert(`첨부 가능: ${ATTACHMENT_TYPE_HINT}`); return; } setUploading(true); try { - const prepared = await prepareImagesForUpload(imgs); + const prepared = await prepareAttachmentsForUpload(picked); onDraftFilesChange?.([...draftFiles, ...prepared]); } catch (e) { window.alert(uploadErrorMessage(e)); @@ -136,18 +159,18 @@ const VerificationIssueAttachmentsSection: React.FC = ({ await addDraftFiles(files); return; } - const imgs = pickImageFiles(files); - if (imgs.length === 0) { - window.alert('이미지 파일(jpeg, png, gif, webp, bmp)만 첨부할 수 있습니다.'); + const picked = pickAttachmentFiles(files); + if (picked.length === 0) { + window.alert(`첨부 가능: ${ATTACHMENT_TYPE_HINT}`); return; } setUploading(true); try { - const prepared = await prepareImagesForUpload(imgs); + const prepared = await prepareAttachmentsForUpload(picked); await uploadVerificationIssueImages(issueId, prepared); await reload(); } catch (e) { - console.warn('[VerificationBoard] image upload', e); + console.warn('[VerificationBoard] attachment upload', e); window.alert(uploadErrorMessage(e)); } finally { setUploading(false); @@ -179,7 +202,7 @@ const VerificationIssueAttachmentsSection: React.FC = ({ const handleDeleteServer = async (imageId: number) => { if (issueId == null) return; - if (!window.confirm('이 이미지를 삭제할까요?')) return; + if (!window.confirm('이 첨부 파일을 삭제할까요?')) return; if (saveOnSubmit) { onPendingDeleteIdsChange?.( pendingDeleteIds.includes(imageId) @@ -201,10 +224,31 @@ const VerificationIssueAttachmentsSection: React.FC = ({ onDraftFilesChange?.(draftFiles.filter((_, i) => i !== index)); }; - const openImage = (src: string) => { + const openAttachment = (src: string) => { window.open(src, '_blank', 'noopener,noreferrer'); }; + const renderAttachmentThumb = ( + src: string, + fileName: string, + isImage: boolean, + kind: string, + onOpen: () => void, + ) => ( + + ); + const handleContextMenu = (e: React.MouseEvent) => { if (disabled) return; e.preventDefault(); @@ -214,18 +258,20 @@ const VerificationIssueAttachmentsSection: React.FC = ({ const handlePaste = (e: React.ClipboardEvent) => { if (disabled) return; - const files = filesFromDataTransfer(e.clipboardData); - if (files.length === 0) return; + const fromItems = filesFromDataTransfer(e.clipboardData); + const fromFiles = pickAttachmentFiles(e.clipboardData.files); + const merged = fromFiles.length > 0 ? fromFiles : fromItems; + if (merged.length === 0) return; e.preventDefault(); - void uploadFiles(files); + void uploadFiles(merged); }; const handleDrop = (e: React.DragEvent) => { if (disabled) return; e.preventDefault(); setDragOver(false); - const fromTransfer = filesFromDataTransfer(e.dataTransfer); - if (fromTransfer.length > 0) void uploadFiles(fromTransfer); + const picked = pickAttachmentFiles(e.dataTransfer.files); + if (picked.length > 0) void uploadFiles(picked); }; const visibleServerImages = useMemo( @@ -242,7 +288,7 @@ const VerificationIssueAttachmentsSection: React.FC = ({

- 첨부 이미지 {totalCount > 0 && `(${totalCount})`} + 첨부 파일 {totalCount > 0 && `(${totalCount})`}

+ {renderAttachmentThumb( + src, + img.fileName ?? '첨부 파일', + isImage, + kind, + () => openAttachment(src), + )}
{img.fileName}
@@ -336,14 +383,19 @@ const VerificationIssueAttachmentsSection: React.FC = ({ {(isDraft || saveOnSubmit) ? draftPreviews.map(p => (
- + {p.isImage && p.src + ? renderAttachmentThumb( + p.src, + p.fileName, + true, + p.kind, + () => openAttachment(p.src!), + ) + : ( +
+ {p.kind} +
+ )}
{p.fileName}
@@ -361,16 +413,17 @@ const VerificationIssueAttachmentsSection: React.FC = ({ : !saveOnSubmit && images.map(img => { if (img.id == null) return null; const src = verificationIssueImageUrl(issueId!, img.id); + const isImage = isImageAttachment(img.contentType, img.fileName); + const kind = fileKindLabel(img.fileName, img.contentType); return (
- + {renderAttachmentThumb( + src, + img.fileName ?? '첨부 파일', + isImage, + kind, + () => openAttachment(src), + )}
{img.fileName}
diff --git a/frontend/src/styles/verificationBoard.css b/frontend/src/styles/verificationBoard.css index ceee095..1a14d05 100644 --- a/frontend/src/styles/verificationBoard.css +++ b/frontend/src/styles/verificationBoard.css @@ -855,6 +855,31 @@ cursor: zoom-in; } +.vbd-attach-thumb--file { + display: flex; + align-items: center; + justify-content: center; + height: 100px; + cursor: pointer; + background: color-mix(in srgb, var(--accent, #3f7ef5) 8%, var(--bg2, #1a1b26)); + border-radius: 6px; +} + +.vbd-attach-file-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 52px; + padding: 6px 10px; + border-radius: 8px; + font-size: 13px; + font-weight: 800; + letter-spacing: 0.04em; + color: var(--accent, #7aa2f7); + background: color-mix(in srgb, var(--accent, #3f7ef5) 16%, transparent); + border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 35%, transparent); +} + .vbd-attach-item img { display: block; width: 100%; diff --git a/frontend/src/utils/verificationAttachmentFiles.ts b/frontend/src/utils/verificationAttachmentFiles.ts new file mode 100644 index 0000000..7095cc6 --- /dev/null +++ b/frontend/src/utils/verificationAttachmentFiles.ts @@ -0,0 +1,100 @@ +/** + * 검증게시판 첨부 — 이미지 + zip·hwp·hwpx + */ +import { + pickImageFiles, + prepareImagesForUpload, +} from './clipboardImageFiles'; + +export const VERIFICATION_ATTACHMENT_ACCEPT = + 'image/jpeg,image/png,image/gif,image/webp,image/bmp,.zip,.hwp,.hwpx,application/zip,application/x-hwp,application/vnd.hancom.hwpx'; + +const DOCUMENT_EXT = new Set(['.zip', '.hwp', '.hwpx']); + +const DOCUMENT_MIME = new Set([ + 'application/zip', + 'application/x-zip-compressed', + 'application/x-hwp', + 'application/haansoft-hwp', + 'application/vnd.hancom.hwp', + 'application/hwp+zip', + 'application/vnd.hancom.hwpx', + 'application/x-hwpx', +]); + +function extOf(name: string): string { + const i = name.lastIndexOf('.'); + return i >= 0 ? name.slice(i).toLowerCase() : ''; +} + +function mimeForDocumentExt(ext: string): string | null { + switch (ext) { + case '.zip': return 'application/zip'; + case '.hwp': return 'application/x-hwp'; + case '.hwpx': return 'application/vnd.hancom.hwpx'; + default: return null; + } +} + +export function isImageAttachment(contentType?: string | null, fileName?: string | null): boolean { + const mime = (contentType ?? '').split(';')[0]?.trim().toLowerCase(); + if (mime.startsWith('image/')) return true; + const ext = fileName ? extOf(fileName) : ''; + return ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'].includes(ext); +} + +export function isDocumentAttachmentFile(file: File): boolean { + const ext = extOf(file.name); + if (DOCUMENT_EXT.has(ext)) return true; + const mime = file.type.split(';')[0]?.trim().toLowerCase(); + return DOCUMENT_MIME.has(mime); +} + +function normalizeDocumentFile(file: File): File | null { + if (file.size === 0) return null; + const ext = extOf(file.name); + if (!DOCUMENT_EXT.has(ext)) { + const mime = file.type.split(';')[0]?.trim().toLowerCase(); + if (!DOCUMENT_MIME.has(mime)) return null; + } + const mime = mimeForDocumentExt(ext) + ?? file.type.split(';')[0]?.trim().toLowerCase() + ?? 'application/octet-stream'; + if (file.type === mime) return file; + return new File([file], file.name, { type: mime, lastModified: file.lastModified }); +} + +/** FileList / File[] → 허용 첨부만 (이미지·zip·hwp·hwpx) */ +export function pickAttachmentFiles(list: FileList | File[]): File[] { + const out: File[] = []; + for (const file of Array.from(list)) { + if (isDocumentAttachmentFile(file)) { + const doc = normalizeDocumentFile(file); + if (doc) out.push(doc); + continue; + } + const img = pickImageFiles([file])[0]; + if (img) out.push(img); + } + return out; +} + +const MAX_BYTES = 5 * 1024 * 1024; + +/** 이미지는 압축·문서는 그대로 업로드 */ +export async function prepareAttachmentsForUpload(files: File[]): Promise { + const picked = pickAttachmentFiles(files); + const out: File[] = []; + for (const f of picked) { + if (isDocumentAttachmentFile(f)) { + if (f.size > MAX_BYTES) throw new Error('FILE_TOO_LARGE'); + out.push(f); + } else { + out.push(...await prepareImagesForUpload([f])); + } + } + return out; +} + +export const ATTACHMENT_TYPE_HINT = + 'jpeg, png, gif, webp, bmp, zip, hwp, hwpx (파일당 5MB)'; diff --git a/frontend/src/utils/verificationBoardApi.ts b/frontend/src/utils/verificationBoardApi.ts index 6312931..35da0b8 100644 --- a/frontend/src/utils/verificationBoardApi.ts +++ b/frontend/src/utils/verificationBoardApi.ts @@ -195,7 +195,7 @@ function parseVerificationUploadError(status: number, text: string): string { if (status === 413) { return '업로드 용량이 너무 큽니다. 이미지를 줄이거나 5MB 이하 파일을 사용하세요.'; } - if (status === 400) return '이미지 형식 또는 크기를 확인하세요. (jpeg, png, gif, webp, bmp · 파일당 5MB)'; + if (status === 400) return '파일 형식 또는 크기를 확인하세요. (jpeg, png, gif, webp, bmp, zip, hwp, hwpx · 파일당 5MB)'; return `이미지 업로드에 실패했습니다. (${status})`; }