From 8b15e6e019192271621949574161f11bd260efcf Mon Sep 17 00:00:00 2001 From: Hyungi Ahn Date: Tue, 28 Apr 2026 13:41:50 +0900 Subject: [PATCH] =?UTF-8?q?feat(study):=20=EB=AC=B8=EC=A0=9C=20=EC=B2=A8?= =?UTF-8?q?=EB=B6=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20(PR-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 문제별 N개 이미지 첨부. 회로도/그래프 등이 필요한 시험 문제 지원. 입력·편집·복습 모두에서 표시. 데이터 모델 (migration 198): - study_question_images: id, user_id FK CASCADE, study_question_id FK CASCADE, file_path, file_size, mime_type, sort_order, created_at - partial idx (study_question_id, sort_order, id) 저장: NAS /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext} file_watcher 가 보는 PKM 경로와 분리 — 자동 인덱싱 안 됨. API: - POST /api/study-questions/{qid}/images (multipart, MIME PNG/JPEG/WEBP/GIF, 10MB/파일 제한, sort_order 자동 max+1) - GET /api/study-questions/{qid}/images/{img_id}/raw (FileResponse, Bearer 인증) - DELETE /api/study-questions/{qid}/images/{img_id} (DB row + 파일 시스템 정리) - StudyQuestionResponse / ReviewQuestionItem 응답에 images 배열 포함 - StudyQuestionSummary 응답에 has_images bool 추가 프론트: - 신규 lib/components/ImgAuth.svelte — Bearer 인증 endpoint 의 이미지를 fetch + blob URL 로 변환해 표시. unmount 시 URL.revokeObjectURL. - /questions/new: 입력 폼에 이미지 dropzone (client-side 보유) → POST /questions 받은 qid 로 자동 multipart 업로드. "저장 후 계속 입력" 시 reset. - /questions/[qid]/edit: 별도 카드 — 기존 이미지 grid + 추가/삭제. 즉시 업로드. - /review: 문제 본문 아래 이미지 grid (max-h-72 object-contain). - 모든 표시는 ImgAuth 컴포넌트 — accessToken 만료 케이스 대비. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/api/study_questions.py | 217 +++++++++++++++++- app/models/study_question_image.py | 31 +++ frontend/src/lib/components/ImgAuth.svelte | 50 ++++ .../[id]/questions/[qid]/edit/+page.svelte | 96 +++++++- .../topics/[id]/questions/new/+page.svelte | 97 +++++++- .../study/topics/[id]/review/+page.svelte | 10 + migrations/198_study_question_images.sql | 21 ++ 7 files changed, 516 insertions(+), 6 deletions(-) create mode 100644 app/models/study_question_image.py create mode 100644 frontend/src/lib/components/ImgAuth.svelte create mode 100644 migrations/198_study_question_images.sql diff --git a/app/api/study_questions.py b/app/api/study_questions.py index 8590272..e308f4c 100644 --- a/app/api/study_questions.py +++ b/app/api/study_questions.py @@ -15,15 +15,18 @@ import random from datetime import datetime, timezone from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse from pydantic import BaseModel, Field from sqlalchemy import and_, case, func, select, text as sql_text, update from sqlalchemy.ext.asyncio import AsyncSession from ai.client import AIClient from core.auth import get_current_user +from core.config import settings from core.database import get_session from models.study_question import StudyQuestion, StudyQuestionAttempt +from models.study_question_image import StudyQuestionImage from models.study_topic import StudyTopic from models.user import User from services.search.llm_gate import get_mlx_gate @@ -97,6 +100,15 @@ class QuestionAttemptStats(BaseModel): wrong_count: int +class StudyQuestionImageItem(BaseModel): + """문제 첨부 이미지 메타. raw bytes 는 별도 GET endpoint.""" + id: int + sort_order: int + mime_type: str + file_size: int + served_url: str # 클라이언트 표시용 — /api/study-questions/{qid}/images/{img_id}/raw + + class StudyQuestionResponse(BaseModel): """편집·관리용 — 정답·해설 모두 노출.""" @@ -121,6 +133,8 @@ class StudyQuestionResponse(BaseModel): ai_explanation_status: str = "none" ai_explanation_generated_at: datetime | None = None ai_explanation_model: str | None = None + # PR-8: 첨부 이미지 + images: list[StudyQuestionImageItem] = [] created_at: datetime updated_at: datetime stats: QuestionAttemptStats @@ -136,6 +150,7 @@ class StudyQuestionSummary(BaseModel): exam_name: str | None exam_round: str | None is_active: bool + has_images: bool = False attempt_count: int last_correct: bool | None created_at: datetime @@ -162,6 +177,7 @@ class ReviewQuestionItem(BaseModel): subject: str | None scope: str | None stats: QuestionAttemptStats + images: list[StudyQuestionImageItem] = [] class ReviewQuestionListResponse(BaseModel): @@ -234,6 +250,64 @@ class AIExplanationResponse(BaseModel): # ─── 통계 헬퍼 ─── +# PR-8: 이미지 저장 root + 헬퍼 +from pathlib import Path as _Path + +STUDY_IMG_ROOT = _Path(settings.nas_mount_path) / "study_question_images" +ALLOWED_IMAGE_MIME = {"image/png", "image/jpeg", "image/webp", "image/gif"} +MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10MB / 파일 + + +def _image_url(qid: int, img_id: int) -> str: + return f"/api/study-questions/{qid}/images/{img_id}/raw" + + +def _image_to_item(img: StudyQuestionImage) -> StudyQuestionImageItem: + return StudyQuestionImageItem( + id=img.id, + sort_order=img.sort_order, + mime_type=img.mime_type, + file_size=img.file_size, + served_url=_image_url(img.study_question_id, img.id), + ) + + +async def _images_for_question( + session: AsyncSession, question_id: int +) -> list[StudyQuestionImageItem]: + rows = ( + await session.execute( + select(StudyQuestionImage) + .where(StudyQuestionImage.study_question_id == question_id) + .order_by(StudyQuestionImage.sort_order.asc(), StudyQuestionImage.id.asc()) + ) + ).scalars().all() + return [_image_to_item(r) for r in rows] + + +async def _images_for_questions_batch( + session: AsyncSession, question_ids: list[int] +) -> dict[int, list[StudyQuestionImageItem]]: + """N+1 회피 — 여러 question 의 이미지 한 번에 조회.""" + if not question_ids: + return {} + rows = ( + await session.execute( + select(StudyQuestionImage) + .where(StudyQuestionImage.study_question_id.in_(question_ids)) + .order_by( + StudyQuestionImage.study_question_id, + StudyQuestionImage.sort_order.asc(), + StudyQuestionImage.id.asc(), + ) + ) + ).scalars().all() + out: dict[int, list[StudyQuestionImageItem]] = {qid: [] for qid in question_ids} + for r in rows: + out.setdefault(r.study_question_id, []).append(_image_to_item(r)) + return out + + async def _attempt_stats( session: AsyncSession, user_id: int, question_id: int ) -> QuestionAttemptStats: @@ -351,6 +425,18 @@ async def list_questions_in_topic( for r in latest_rows: last_correct_map[r.study_question_id] = bool(r.is_correct) + # PR-8: has_images batch + has_image_set: set[int] = set() + if qids: + img_rows = ( + await session.execute( + select(StudyQuestionImage.study_question_id) + .where(StudyQuestionImage.study_question_id.in_(qids)) + .distinct() + ) + ).scalars().all() + has_image_set = set(img_rows) + items = [ StudyQuestionSummary( id=q.id, @@ -362,6 +448,7 @@ async def list_questions_in_topic( is_active=q.is_active, attempt_count=stats_map.get(q.id, (0, 0))[0], last_correct=last_correct_map.get(q.id), + has_images=q.id in has_image_set, created_at=q.created_at, ) for q in rows @@ -442,6 +529,7 @@ async def create_question_in_topic( ai_explanation_status=q.ai_explanation_status, ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_model=q.ai_explanation_model, + images=await _images_for_question(session, q.id), created_at=q.created_at, updated_at=q.updated_at, stats=stats, @@ -579,6 +667,9 @@ async def review_questions_for_topic( attempt_count=t, correct_count=c, wrong_count=t - c ) + # PR-8: 이미지 batch + images_map = await _images_for_questions_batch(session, qids) + items = [ ReviewQuestionItem( id=q.id, @@ -594,6 +685,7 @@ async def review_questions_for_topic( stats=stats_map.get( q.id, QuestionAttemptStats(attempt_count=0, correct_count=0, wrong_count=0) ), + images=images_map.get(q.id, []), ) for q in selected ] @@ -637,6 +729,7 @@ async def get_question( ai_explanation_status=q.ai_explanation_status, ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_model=q.ai_explanation_model, + images=await _images_for_question(session, q.id), created_at=q.created_at, updated_at=q.updated_at, stats=stats, @@ -705,6 +798,7 @@ async def update_question( ai_explanation_status=q.ai_explanation_status, ai_explanation_generated_at=q.ai_explanation_generated_at, ai_explanation_model=q.ai_explanation_model, + images=await _images_for_question(session, q.id), created_at=q.created_at, updated_at=q.updated_at, stats=stats, @@ -875,6 +969,127 @@ async def list_similar_questions( return SimilarQuestionsResponse(items=items, source_status="ready", source_id=question_id) +# ─── PR-8: 이미지 업로드/조회/삭제 ─── + + +@router.post( + "/study-questions/{question_id}/images", + response_model=StudyQuestionImageItem, + status_code=201, +) +async def upload_question_image( + question_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], + file: UploadFile = File(...), +): + """문제 첨부 이미지 업로드. 1파일 = 1행. 여러 호출로 여러 이미지 추가. + + 경로: /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext} + 인증: 같은 user 의 question 만. 다른 사용자는 404. + 제한: PNG/JPEG/WEBP/GIF, 10MB/파일. + """ + q = await session.get(StudyQuestion, question_id) + q = _verify_question_ownership(q, user) + + # MIME 검증 + mime = (file.content_type or "").lower() + if mime not in ALLOWED_IMAGE_MIME: + raise HTTPException( + status_code=415, + detail=f"지원하지 않는 이미지 형식: {mime} (PNG/JPEG/WEBP/GIF 만 허용)", + ) + + # 파일 읽기 + 크기 검증 + raw = await file.read() + if len(raw) > MAX_IMAGE_BYTES: + raise HTTPException( + status_code=413, + detail=f"이미지 크기 초과 ({len(raw)} > {MAX_IMAGE_BYTES} 바이트, 10MB 제한)", + ) + if len(raw) == 0: + raise HTTPException(status_code=400, detail="빈 파일") + + # 확장자 결정 + ext_map = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/gif": "gif", + } + ext = ext_map[mime] + + # DB 행 먼저 만들고 id 받음 (파일명에 사용) + img = StudyQuestionImage( + user_id=user.id, + study_question_id=q.id, + file_path="", # 일단 빈 값, id 받은 후 채움 + file_size=len(raw), + mime_type=mime, + sort_order=0, + ) + session.add(img) + await session.flush() # img.id 확보 + + # 파일 시스템 저장 + target_dir = STUDY_IMG_ROOT / str(q.study_topic_id) / str(q.id) + target_dir.mkdir(parents=True, exist_ok=True) + target_path = target_dir / f"{img.id}.{ext}" + target_path.write_bytes(raw) + + img.file_path = str(target_path) + # sort_order = 같은 question 의 max+1 + max_sort = ( + await session.execute( + select(func.coalesce(func.max(StudyQuestionImage.sort_order), -1)) + .where(StudyQuestionImage.study_question_id == q.id) + .where(StudyQuestionImage.id != img.id) + ) + ).scalar() or -1 + img.sort_order = int(max_sort) + 1 + await session.commit() + + return _image_to_item(img) + + +@router.get("/study-questions/{question_id}/images/{image_id}/raw") +async def get_question_image_raw( + question_id: int, + image_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + """이미지 raw bytes. 같은 user 의 question 첨부만 응답.""" + img = await session.get(StudyQuestionImage, image_id) + if img is None or img.user_id != user.id or img.study_question_id != question_id: + raise HTTPException(status_code=404, detail="이미지를 찾을 수 없습니다") + file_path = _Path(img.file_path) + if not file_path.is_file(): + raise HTTPException(status_code=410, detail="파일이 사라졌습니다") + return FileResponse(str(file_path), media_type=img.mime_type) + + +@router.delete("/study-questions/{question_id}/images/{image_id}", status_code=204) +async def delete_question_image( + question_id: int, + image_id: int, + user: Annotated[User, Depends(get_current_user)], + session: Annotated[AsyncSession, Depends(get_session)], +): + img = await session.get(StudyQuestionImage, image_id) + if img is None or img.user_id != user.id or img.study_question_id != question_id: + raise HTTPException(status_code=404, detail="이미지를 찾을 수 없습니다") + # 파일 시스템 삭제 (실패해도 DB row 는 정리) + try: + file_path = _Path(img.file_path) + if file_path.is_file(): + file_path.unlink() + except Exception as e: + logger.warning("study_q_image_unlink_failed id=%s: %s", image_id, e) + await session.delete(img) + await session.commit() + + # ─── PR-3: AI 풀이 생성 엔드포인트 ─── # MLX 호출 timeout (초). MLX gate + 26B 추론 평균 ~10s, 안전 마진. diff --git a/app/models/study_question_image.py b/app/models/study_question_image.py new file mode 100644 index 0000000..99d09ff --- /dev/null +++ b/app/models/study_question_image.py @@ -0,0 +1,31 @@ +"""study_question_images ORM (PR-8) — 문제별 첨부 이미지. + +저장: NAS /documents/study_question_images/{topic_id}/{qid}/{img_id}.{ext} +표시: GET /api/study-questions/{qid}/images/{img_id}/raw (인증 필요) +""" + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from core.database import Base + + +class StudyQuestionImage(Base): + __tablename__ = "study_question_images" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + user_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("users.id", ondelete="CASCADE"), nullable=False + ) + study_question_id: Mapped[int] = mapped_column( + BigInteger, ForeignKey("study_questions.id", ondelete="CASCADE"), nullable=False + ) + file_path: Mapped[str] = mapped_column(Text, nullable=False) + file_size: Mapped[int] = mapped_column(BigInteger, nullable=False) + mime_type: Mapped[str] = mapped_column(String(80), nullable=False) + sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=datetime.now, nullable=False + ) diff --git a/frontend/src/lib/components/ImgAuth.svelte b/frontend/src/lib/components/ImgAuth.svelte new file mode 100644 index 0000000..6452b6f --- /dev/null +++ b/frontend/src/lib/components/ImgAuth.svelte @@ -0,0 +1,50 @@ + + +{#if errored} +
+ 이미지 로드 실패 +
+{:else if blobUrl} + +{:else} +
+{/if} diff --git a/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte b/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte index 841f6ad..83fbfd8 100644 --- a/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/questions/[qid]/edit/+page.svelte @@ -9,15 +9,16 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { api } from '$lib/api'; + import { api, uploadFile } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { ArrowLeft, Save, Trash2, AlertCircle, Sparkles, GitCompare, ArrowRight, CheckCircle2, XCircle } from 'lucide-svelte'; + import { ArrowLeft, Save, Trash2, AlertCircle, Sparkles, GitCompare, ArrowRight, CheckCircle2, XCircle, ImagePlus, X } from 'lucide-svelte'; import { renderMathMarkdown } from '$lib/utils/mathMarkdown'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; import Skeleton from '$lib/components/ui/Skeleton.svelte'; import TextInput from '$lib/components/ui/TextInput.svelte'; import Textarea from '$lib/components/ui/Textarea.svelte'; + import ImgAuth from '$lib/components/ImgAuth.svelte'; let topicId = $derived(Number($page.params.id)); let questionId = $derived(Number($page.params.qid)); @@ -36,6 +37,58 @@ let is_active = $state(true); let stats = $state({ attempt_count: 0, correct_count: 0, wrong_count: 0 }); + // PR-8: 첨부 이미지 + let images = $state([]); // [{id, served_url, mime_type, file_size, sort_order}] + let imgUploading = $state(false); + + async function refreshImages() { + try { + const q = await api(`/study-questions/${questionId}`); + images = q.images ?? []; + } catch {} + } + + async function uploadImages(e) { + const files = Array.from(e.target.files ?? []); + if (files.length === 0) return; + imgUploading = true; + let ok = 0; + for (const f of files) { + if (!f.type.startsWith('image/')) { + addToast('error', `이미지 파일이 아닙니다: ${f.name}`); + continue; + } + if (f.size > 10 * 1024 * 1024) { + addToast('error', `${f.name} 10MB 초과`); + continue; + } + const fd = new FormData(); + fd.append('file', f, f.name); + try { + await uploadFile(`/study-questions/${questionId}/images`, fd); + ok += 1; + } catch (err) { + addToast('error', `${f.name} 업로드 실패: ${err?.detail || err?.status || '알수없음'}`); + } + } + imgUploading = false; + e.target.value = ''; + if (ok > 0) { + await refreshImages(); + addToast('success', `${ok}건 업로드됨`); + } + } + + async function deleteImage(imgId) { + if (!confirm('이미지를 삭제합니다.')) return; + try { + await api(`/study-questions/${questionId}/images/${imgId}`, { method: 'DELETE' }); + images = images.filter((x) => x.id !== imgId); + } catch (err) { + addToast('error', err?.detail || '삭제 실패'); + } + } + // PR-5: 비슷한 문제 검색 결과 let similarLoading = $state(false); let similarItems = $state([]); @@ -112,6 +165,8 @@ aiStatus = q.ai_explanation_status ?? 'none'; aiGeneratedAt = q.ai_explanation_generated_at; aiModel = q.ai_explanation_model; + // PR-8: 이미지 prefill + images = q.images ?? []; // PR-5: 비슷한 문제 자동 로드 (페이지 로드와 병렬) loadSimilar(); } catch (err) { @@ -244,6 +299,43 @@ {/snippet} + + + {#snippet children()} +
+
+
+ + 첨부 이미지 + {images.length}건 +
+ +
+ {#if images.length === 0} +
첨부된 이미지가 없습니다. PNG/JPEG/WEBP/GIF, 10MB/파일.
+ {:else} +
+ {#each images as img (img.id)} +
+ +
{Math.round(img.file_size/1024)} KB
+ +
+ {/each} +
+ {/if} +
+ {/snippet} +
+ {#snippet children()} diff --git a/frontend/src/routes/study/topics/[id]/questions/new/+page.svelte b/frontend/src/routes/study/topics/[id]/questions/new/+page.svelte index 668e74e..8311ea3 100644 --- a/frontend/src/routes/study/topics/[id]/questions/new/+page.svelte +++ b/frontend/src/routes/study/topics/[id]/questions/new/+page.svelte @@ -15,9 +15,9 @@ import { onMount } from 'svelte'; import { page } from '$app/stores'; import { goto } from '$app/navigation'; - import { api } from '$lib/api'; + import { api, uploadFile } from '$lib/api'; import { addToast } from '$lib/stores/toast'; - import { ArrowLeft, Save, Repeat, ListChecks, AlertCircle } from 'lucide-svelte'; + import { ArrowLeft, Save, Repeat, ListChecks, AlertCircle, ImagePlus, X } from 'lucide-svelte'; import Button from '$lib/components/ui/Button.svelte'; import Card from '$lib/components/ui/Card.svelte'; import TextInput from '$lib/components/ui/TextInput.svelte'; @@ -61,6 +61,59 @@ let saving = $state(false); let isCompleteRound = $state(false); // 회차 도달 여부 + // PR-8: 첨부 이미지 (저장 전 client-side 보유 → 저장 후 자동 업로드) + /** @type {{file: File, previewUrl: string}[]} */ + let pendingImages = $state([]); + let uploading = $state(false); + + function onPickImages(e) { + const files = Array.from(e.target.files ?? []); + for (const f of files) { + if (!f.type.startsWith('image/')) { + addToast('error', `이미지 파일이 아닙니다: ${f.name}`); + continue; + } + if (f.size > 10 * 1024 * 1024) { + addToast('error', `${f.name} 10MB 초과`); + continue; + } + pendingImages = [...pendingImages, { file: f, previewUrl: URL.createObjectURL(f) }]; + } + e.target.value = ''; // 같은 파일 재선택 가능 + } + + function removePending(idx) { + const item = pendingImages[idx]; + if (item) URL.revokeObjectURL(item.previewUrl); + pendingImages = pendingImages.filter((_, i) => i !== idx); + } + + async function uploadPendingImages(qid) { + if (pendingImages.length === 0) return 0; + uploading = true; + let success = 0; + try { + for (const it of pendingImages) { + const fd = new FormData(); + fd.append('file', it.file, it.file.name); + try { + await uploadFile(`/study-questions/${qid}/images`, fd); + success += 1; + } catch (err) { + addToast('error', `${it.file.name} 업로드 실패: ${err?.detail || err?.status || '알수없음'}`); + } + } + } finally { + uploading = false; + } + return success; + } + + function clearPendingImages() { + for (const it of pendingImages) URL.revokeObjectURL(it.previewUrl); + pendingImages = []; + } + const STORAGE_KEY = $derived(`study_q_persist_v2_${topicId}`); function persist() { @@ -276,9 +329,18 @@ // 응답의 실제 저장값으로 표시 동기화 (서버가 결정한 qnum) const actualQnum = saved?.exam_question_number ?? null; if (actualQnum) f_qnum = actualQnum; - addToast('success', `문제 저장됨${f_exam_round && actualQnum ? ` (${f_exam_round} ${actualQnum}번)` : ''}`); + + // PR-8: 첨부 이미지 자동 업로드 (qid 받은 후) + let imgMsg = ''; + if (pendingImages.length > 0 && saved?.id) { + const okCount = await uploadPendingImages(saved.id); + imgMsg = ` · 이미지 ${okCount}/${pendingImages.length}건 첨부`; + } + + addToast('success', `문제 저장됨${f_exam_round && actualQnum ? ` (${f_exam_round} ${actualQnum}번)` : ''}${imgMsg}`); if (continueAfter) { clearForCont(); + clearPendingImages(); // 다음 표시값 = 방금 저장된 qnum + 1. 사용자가 다시 수정하기 전까지는 // 자동(서버 max+1) 모드 유지. if (actualQnum) f_qnum = actualQnum + 1; @@ -449,6 +511,35 @@