feat(study): 문제 첨부 이미지 (PR-8)

문제별 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 로 변환해 <img> 표시. 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) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-28 13:41:50 +09:00
parent df52cb191b
commit 8b15e6e019
7 changed files with 516 additions and 6 deletions
+216 -1
View File
@@ -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, 안전 마진.
+31
View File
@@ -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
)
@@ -0,0 +1,50 @@
<script>
/**
* Bearer 인증 이미지 endpoint 를 <img> 로 표시.
* fetch + blob URL 패턴 — img 태그가 Authorization 헤더를 못 보내는 한계 우회.
* unmount 시 URL.revokeObjectURL 로 메모리 정리.
*/
import { getAccessToken } from '$lib/api';
let { src, alt = '', class: klass = '' } = $props();
let blobUrl = $state('');
let errored = $state(false);
$effect(() => {
let active = true;
let url = '';
if (!src) return () => {};
(async () => {
try {
const accessToken = getAccessToken();
const res = await fetch(src, {
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : {},
credentials: 'include',
});
if (!res.ok) {
if (active) errored = true;
return;
}
const blob = await res.blob();
url = URL.createObjectURL(blob);
if (active) blobUrl = url;
} catch {
if (active) errored = true;
}
})();
return () => {
active = false;
if (url) URL.revokeObjectURL(url);
};
});
</script>
{#if errored}
<div class={`bg-error/10 border border-error/40 text-error text-[10px] flex items-center justify-center ${klass}`}>
이미지 로드 실패
</div>
{:else if blobUrl}
<img src={blobUrl} {alt} class={klass} />
{:else}
<div class={`bg-bg/40 animate-pulse ${klass}`}></div>
{/if}
@@ -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}
</Card>
<!-- PR-8: 첨부 이미지 -->
<Card class="mb-3">
{#snippet children()}
<div class="p-4 flex flex-col gap-3">
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-2">
<ImagePlus size={16} class="text-accent" />
<span class="text-sm font-semibold text-text">첨부 이미지</span>
<span class="text-[11px] text-dim">{images.length}</span>
</div>
<label class="cursor-pointer text-xs text-accent hover:underline flex items-center gap-1">
{#if imgUploading}<span>업로드 중...</span>{:else}<><ImagePlus size={14} /> 이미지 추가</>{/if}
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple
onchange={uploadImages} disabled={imgUploading} class="hidden" />
</label>
</div>
{#if images.length === 0}
<div class="text-[11px] text-dim">첨부된 이미지가 없습니다. PNG/JPEG/WEBP/GIF, 10MB/파일.</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{#each images as img (img.id)}
<div class="relative border border-default rounded overflow-hidden bg-bg/30">
<ImgAuth src={img.served_url} class="w-full h-28 object-cover" />
<div class="px-2 py-1 text-[10px] text-dim truncate">{Math.round(img.file_size/1024)} KB</div>
<button type="button" onclick={() => deleteImage(img.id)}
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center hover:bg-error"
aria-label="삭제">
<X size={12} />
</button>
</div>
{/each}
</div>
{/if}
</div>
{/snippet}
</Card>
<!-- PR-3: AI 풀이 섹션 -->
<Card class="mb-3">
{#snippet children()}
@@ -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 @@
<Textarea label="해설 (선택)" bind:value={explanation} rows={2} placeholder="정답 근거 요약" />
<TextInput label="출처/메모 (자동: '회차 N번', 수정 가능)" bind:value={source_note}
placeholder={autoSourceNote() || "예: 산업안전기사 2023 1회 기출 7번"} />
<!-- PR-8: 이미지 첨부 -->
<div class="flex flex-col gap-2 pt-3 border-t border-default">
<div class="flex items-center justify-between">
<span class="text-xs text-dim">이미지 (선택, PNG/JPEG/WEBP/GIF, 10MB/파일)</span>
<label class="cursor-pointer text-xs text-accent hover:underline flex items-center gap-1">
<ImagePlus size={14} /> 이미지 추가
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple
onchange={onPickImages} class="hidden" />
</label>
</div>
{#if pendingImages.length > 0}
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
{#each pendingImages as it, idx (it.previewUrl)}
<div class="relative border border-default rounded overflow-hidden bg-bg/30">
<img src={it.previewUrl} alt={it.file.name} class="w-full h-24 object-cover" />
<div class="px-2 py-1 text-[10px] text-dim truncate">{it.file.name}</div>
<button type="button" onclick={() => removePending(idx)}
class="absolute top-1 right-1 w-5 h-5 rounded-full bg-black/60 text-white flex items-center justify-center hover:bg-error"
aria-label="제거">
<X size={12} />
</button>
</div>
{/each}
</div>
{:else}
<div class="text-[11px] text-dim">첨부할 이미지가 없습니다.</div>
{/if}
</div>
</div>
{/snippet}
</Card>
@@ -17,6 +17,7 @@
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 ImgAuth from '$lib/components/ImgAuth.svelte';
let topicId = $derived(Number($page.params.id));
let topicName = $state('');
@@ -271,6 +272,15 @@
<div class="p-5 flex flex-col gap-4">
<div class="text-base text-text leading-relaxed math-area">{@html renderMathMarkdown(currentQ.question_text)}</div>
<!-- PR-8: 첨부 이미지 (문제 본문 아래) -->
{#if currentQ.images?.length > 0}
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mt-1">
{#each currentQ.images as img (img.id)}
<ImgAuth src={img.served_url} class="w-full max-h-72 object-contain rounded border border-default bg-bg/30" />
{/each}
</div>
{/if}
<div class="flex flex-col gap-2">
{#each currentQ.choices as ch (ch.number)}
{@const isSelected = selected === ch.number}
+21
View File
@@ -0,0 +1,21 @@
-- 198_study_question_images.sql
-- 문제별 첨부 이미지 (PR-8). 한 문제에 여러 이미지 가능 (회로도+그래프 등).
--
-- 저장 위치: NAS `/documents/study_question_images/{topic_id}/{question_id}/{image_id}.{ext}`
-- (file_watcher 가 보는 PKM 경로와 분리 — 자동 인덱싱 안 됨).
--
-- soft delete 미사용 (이미지는 hard delete + 파일 시스템 정리). 충분히 작은 자원.
CREATE TABLE IF NOT EXISTS study_question_images (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
study_question_id BIGINT NOT NULL REFERENCES study_questions(id) ON DELETE CASCADE,
file_path TEXT NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(80) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_study_question_images_qid
ON study_question_images (study_question_id, sort_order, id);