From 01f25fc6c71428a642f80e92acb8fe30639f481d Mon Sep 17 00:00:00 2001 From: Junseo1026 Date: Wed, 24 Sep 2025 19:14:24 +0900 Subject: [PATCH] upload/preview --- models/file.py | 6 ++-- routers/file.py | 67 ++++++++++++++++++------------------- routers/note.py | 88 ++++++++++++++++++++++++++++++++----------------- schemas/note.py | 24 ++++++++++++-- 4 files changed, 113 insertions(+), 72 deletions(-) diff --git a/models/file.py b/models/file.py index 7bef388..573971b 100644 --- a/models/file.py +++ b/models/file.py @@ -1,4 +1,3 @@ -# Backend/models/file.py from sqlalchemy import Column, Integer, String, ForeignKey, TIMESTAMP, text from .base import Base @@ -8,7 +7,8 @@ class File(Base): id = Column(Integer, primary_key=True, autoincrement=True) user_id = Column(Integer, ForeignKey('user.u_id', ondelete='CASCADE'), nullable=False) folder_id = Column(Integer, ForeignKey('folder.id', ondelete='SET NULL'), nullable=True) + note_id = Column(Integer, ForeignKey('note.id', ondelete='SET NULL'), nullable=True) # ✅ 첨부된 노트 ID original_name = Column(String(255), nullable=False) # 유저가 업로드한 원본 파일 이름 - saved_path = Column(String(512), nullable=False) # 서버에 저장된(실제) 경로 - content_type = Column(String(100), nullable=False) # MIME 타입 + saved_path = Column(String(512), nullable=False) # 서버에 저장된(실제) 경로 + content_type = Column(String(100), nullable=False) # MIME 타입 created_at = Column(TIMESTAMP, nullable=False, server_default=text('CURRENT_TIMESTAMP')) diff --git a/routers/file.py b/routers/file.py index 1a43a25..602d489 100644 --- a/routers/file.py +++ b/routers/file.py @@ -1,5 +1,3 @@ -# ~/noteflow/Backend/routers/file.py - import os from datetime import datetime from typing import Optional, List @@ -71,11 +69,12 @@ def run(cmd: list[str]): @router.post( "/upload", - summary="폴더에 파일 업로드", + summary="폴더/노트에 파일 업로드 (note_id 있으면 노트 본문에도 삽입)", status_code=status.HTTP_201_CREATED ) async def upload_file( folder_id: Optional[int] = Form(None), + note_id: Optional[int] = Form(None), upload_file: UploadFile = File(...), db: Session = Depends(get_db), current_user = Depends(get_current_user) @@ -87,7 +86,7 @@ async def upload_file( user_dir = os.path.join(BASE_UPLOAD_DIR, str(current_user.u_id)) os.makedirs(user_dir, exist_ok=True) - # 원본 파일명 그대로 저장 (동명이인 방지) + # 원본 파일명 그대로 저장 (중복 시 _1, _2 붙임) saved_filename = orig_filename saved_path = os.path.join(user_dir, saved_filename) if os.path.exists(saved_path): @@ -110,10 +109,22 @@ async def upload_file( except Exception as e: raise HTTPException(status_code=500, detail=f"파일 저장 실패: {e}") + # note_id가 있으면 해당 노트 확인 + note_obj = None + if note_id is not None: + note_obj = ( + db.query(NoteModel) + .filter(NoteModel.id == note_id, NoteModel.user_id == current_user.u_id) + .first() + ) + if not note_obj: + raise HTTPException(status_code=404, detail="해당 노트를 찾을 수 없습니다.") + # DB에 메타데이터 기록 new_file = FileModel( user_id=current_user.u_id, - folder_id=folder_id, + folder_id=None if note_id else folder_id, + note_id=note_id, original_name=orig_filename, saved_path=saved_path, content_type=content_type @@ -125,11 +136,25 @@ async def upload_file( base_url = os.getenv("BASE_API_URL", "http://localhost:8000") download_url = f"{base_url}/api/v1/files/download/{new_file.id}" + # note_id가 있으면 content에도 삽입 + if note_obj: + if content_type.startswith("image/"): + embed = f"\n\n![{new_file.original_name}]({download_url})\n\n" + elif content_type == "application/pdf": + embed = f"\n\n[{new_file.original_name}]({download_url}) (PDF 보기)\n\n" + else: + embed = f"\n\n[{new_file.original_name}]({download_url})\n\n" + + note_obj.content = (note_obj.content or "") + embed + db.commit() + db.refresh(note_obj) + return { "file_id": new_file.id, "url": download_url, "original_name": new_file.original_name, "folder_id": new_file.folder_id, + "note_id": new_file.note_id, "content_type": new_file.content_type, "created_at": new_file.created_at } @@ -180,13 +205,6 @@ def download_file( if not os.path.exists(file_path): raise HTTPException(status_code=404, detail="서버에 파일이 존재하지 않습니다.") - # filename_star = file_obj.original_name - # return FileResponse( - # path=file_path, - # media_type=file_obj.content_type, - # headers={"Content-Disposition": f"inline; filename*=UTF-8''{filename_star}"} - # ) - # FastAPI가 내부에서 UTF-8로 인코딩된 Content-Disposition 헤더를 생성해 줌 return FileResponse( path=file_path, media_type=file_obj.content_type, @@ -201,7 +219,6 @@ def download_file( response_model=OCRResponse ) async def ocr_and_create_note( - # 변경: 업로드 필드명 'file' 기본 + 과거 호환 'ocr_file' 동시 허용 file: Optional[UploadFile] = File(None, description="기본 업로드 필드명"), ocr_file: Optional[UploadFile] = File(None, description="과거 호환 업로드 필드명"), folder_id: Optional[int] = Form(None), @@ -210,13 +227,6 @@ async def ocr_and_create_note( db: Session = Depends(get_db), current_user = Depends(get_current_user) ): - """ - 변경 전: 이미지 전용 EasyOCR/TrOCR로 텍스트 추출 후 노트 생성. - 변경 후(추가/변경): 공통 파이프라인(utils.ocr.run_pipeline)으로 이미지/PDF/DOC/DOCX/HWP 처리. - - 예외는 200으로 내려가며, results=[] + warnings에 사유 기입. - - 결과 텍스트를 합쳐 비어있지 않으면 기존과 동일하게 노트를 생성. - """ - # 업로드 파일 결정 upload = file or ocr_file if upload is None: raise HTTPException(status_code=400, detail="업로드 파일이 필요합니다. 필드명은 'file' 또는 'ocr_file'을 사용하세요.") @@ -224,7 +234,6 @@ async def ocr_and_create_note( filename = upload.filename or "uploaded" mime = upload.content_type - # 허용 확장자 확인 (불일치 시 200 + warnings) _, ext = os.path.splitext(filename) ext = ext.lower() if ext and ext not in ALLOWED_ALL_EXTS: @@ -238,7 +247,6 @@ async def ocr_and_create_note( text=None, ) - # 타입 판별 (보조적으로 unknown 방지) ftype = detect_type(filename, mime) if ftype == "unknown": return OCRResponse( @@ -268,7 +276,6 @@ async def ocr_and_create_note( note_id: Optional[int] = None if merged_text: try: - # 추가/변경: 노트 제목을 업로드한 파일 이름으로 설정 (확장자 제거) base_title = os.path.splitext(filename)[0].strip() or "OCR 결과" new_note = NoteModel( user_id=current_user.u_id, @@ -297,24 +304,21 @@ async def upload_audio_and_transcribe( db: Session = Depends(get_db), user=Depends(get_current_user) ): - # 📁 저장 경로 생성 timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"user{user.u_id}_{timestamp}_{file.filename}" save_dir = os.path.join(BASE_UPLOAD_DIR, str(user.u_id)) os.makedirs(save_dir, exist_ok=True) save_path = os.path.join(save_dir, filename) - # 📥 파일 저장 with open(save_path, "wb") as f: f.write(await file.read()) - # ✅ note_id가 있으면 folder_id는 무시 folder_id_to_use = folder_id if note_id is None else None - # 📦 files 테이블에 기록 new_file = FileModel( user_id=user.u_id, folder_id=folder_id_to_use, + note_id=note_id, original_name=filename, saved_path=save_path, content_type="audio" @@ -323,7 +327,6 @@ async def upload_audio_and_transcribe( db.commit() db.refresh(new_file) - # 🧠 STT 처리 try: import whisper model = whisper.load_model("base") @@ -332,9 +335,7 @@ async def upload_audio_and_transcribe( except Exception as e: raise HTTPException(status_code=500, detail=f"STT 처리 실패: {e}") - # 📝 노트 처리 if note_id: - # 기존 노트에 텍스트 추가 note = db.query(NoteModel).filter( NoteModel.id == note_id, NoteModel.user_id == user.u_id @@ -349,7 +350,6 @@ async def upload_audio_and_transcribe( db.refresh(note) else: - # 새 노트 생성 new_note = NoteModel( user_id=user.u_id, folder_id=folder_id_to_use, @@ -364,10 +364,7 @@ async def upload_audio_and_transcribe( "message": "STT 및 노트 저장 완료", "transcript": transcript } + @router.options("/ocr") def ocr_cors_preflight() -> Response: - """CORS preflight용 OPTIONS 응답. 일부 프록시/클라이언트에서 405 회피. - 변경 전: 별도 OPTIONS 라우트 없음(미들웨어에 의존) - 변경 후(추가): 명시적으로 200을 반환 - """ return Response(status_code=200) diff --git a/routers/note.py b/routers/note.py index 3e7f1cc..b2038c3 100644 --- a/routers/note.py +++ b/routers/note.py @@ -9,9 +9,9 @@ from db import get_db, SessionLocal from models.note import Note -from schemas.note import NoteCreate, NoteUpdate, NoteResponse, FavoriteUpdate +from models.file import File as FileModel +from schemas.note import NoteCreate, NoteUpdate, NoteResponse, FavoriteUpdate, NoteFile from utils.jwt_utils import get_current_user -from fastapi.responses import StreamingResponse from utils.llm import stream_summary_with_langchain load_dotenv() @@ -19,6 +19,44 @@ router = APIRouter(prefix="/api/v1", tags=["Notes"]) +# 환경변수에서 BASE_API_URL 가져와 파일 다운로드 URL 구성 +BASE_API_URL = os.getenv("BASE_API_URL", "http://localhost:8000") + + +# ───────────────────────────────────────────── +# 공통: Note → NoteResponse 직렬화 + files 채우기 +# ───────────────────────────────────────────── +def serialize_note(db: Session, note: Note) -> NoteResponse: + """ + Note ORM 객체를 NoteResponse로 변환하면서 + note_id로 연결된 File들을 찾아 files 배열에 채워 넣는다. + """ + # 파일 목록 조회 (노트 첨부) + files = ( + db.query(FileModel) + .filter(FileModel.note_id == note.id, FileModel.user_id == note.user_id) + .order_by(FileModel.created_at.desc()) + .all() + ) + + file_items: List[NoteFile] = [] + for f in files: + file_items.append( + NoteFile( + file_id=f.id, + original_name=f.original_name, + content_type=f.content_type, + url=f"{BASE_API_URL}/api/v1/files/download/{f.id}", + created_at=f.created_at, + ) + ) + + # Pydantic 모델 생성 (from_attributes=True로 기본 필드 매핑) + data = NoteResponse.model_validate(note, from_attributes=True) + # files 필드 교체 + data.files = file_items + return data + # 1) 모든 노트 조회 @router.get("/notes", response_model=List[NoteResponse]) @@ -26,12 +64,14 @@ def list_notes( db: Session = Depends(get_db), user = Depends(get_current_user) ): - return ( + notes = ( db.query(Note) .filter(Note.user_id == user.u_id) .order_by(Note.created_at.desc()) .all() ) + # 각 노트의 files도 채워 반환 + return [serialize_note(db, n) for n in notes] # 2) 최근 접근한 노트 조회 (상위 10개) @@ -40,13 +80,14 @@ def recent_notes( db: Session = Depends(get_db), user = Depends(get_current_user) ): - return ( + notes = ( db.query(Note) .filter(Note.user_id == user.u_id, Note.last_accessed.isnot(None)) .order_by(Note.last_accessed.desc()) .limit(10) .all() ) + return [serialize_note(db, n) for n in notes] # 3) 노트 생성 @@ -65,7 +106,7 @@ def create_note( db.add(note) db.commit() db.refresh(note) - return note + return serialize_note(db, note) # 4) 노트 수정 (제목/내용/폴더) @@ -86,11 +127,13 @@ def update_note( note.content = req.content if req.folder_id is not None: note.folder_id = req.folder_id + if req.is_favorite is not None: + note.is_favorite = req.is_favorite note.updated_at = datetime.utcnow() db.commit() db.refresh(note) - return note + return serialize_note(db, note) # 5) 노트 단일 조회 (마지막 접근 시간 업데이트 포함) @@ -107,7 +150,7 @@ def get_note( note.last_accessed = datetime.utcnow() db.commit() db.refresh(note) - return note + return serialize_note(db, note) # 6) 노트 삭제 @@ -142,30 +185,13 @@ def toggle_favorite( note.updated_at = datetime.utcnow() db.commit() db.refresh(note) - return note - -def save_summary(note_id: int, text: str): - """Deprecated: kept for reference. No longer overwrites original note.""" - db2 = SessionLocal() - try: - tgt = db2.query(Note).filter(Note.id == note_id).first() - if not tgt: - return - # Create a new note in the same folder with title '요약' - title = (tgt.title or "").strip() + "요약" - if len(title) > 255: - title = title[:255] - new_note = Note( - user_id=tgt.user_id, - folder_id=tgt.folder_id, - title=title, - content=text, - ) - db2.add(new_note) - db2.commit() - finally: - db2.close() + return serialize_note(db, note) + +# ───────────────────────────────────────────── +# (참고) 요약 스트리밍 API - 완료 후에도 serialize_note 사용 안 함 +# (요약은 새 노트를 생성하고 SSE로 알림만 보냄) +# ───────────────────────────────────────────── @router.post("/notes/{note_id}/summarize") async def summarize_stream_langchain( note_id: int, @@ -183,7 +209,7 @@ async def event_gen(): parts = [] async for sse in stream_summary_with_langchain(note.content, domain=domain, longdoc=longdoc): parts.append(sse.removeprefix("data: ").strip()) - yield sse.encode() + yield sse.encode() full = "".join(parts).strip() if full: # Create a new summary note in the same folder with title '요약' diff --git a/schemas/note.py b/schemas/note.py index c0d8f33..018d167 100644 --- a/schemas/note.py +++ b/schemas/note.py @@ -1,14 +1,27 @@ -# src/schemas/note.py - from pydantic import BaseModel -from typing import Optional +from typing import Optional, List from datetime import datetime +# ───────────────────────────────────────────── +# 첨부 파일 스키마 (노트 응답에 포함) +# ───────────────────────────────────────────── +class NoteFile(BaseModel): + file_id: int + original_name: str + content_type: str + url: str + created_at: datetime + + class Config: + from_attributes = True + + class NoteCreate(BaseModel): title: str content: Optional[str] = None folder_id: Optional[int] = None + class NoteUpdate(BaseModel): title: Optional[str] = None content: Optional[str] = None @@ -16,9 +29,11 @@ class NoteUpdate(BaseModel): # 필요에 따라 is_favorite 같은 필드도 추가 가능 is_favorite: Optional[bool] = None + class FavoriteUpdate(BaseModel): is_favorite: bool + class NoteResponse(BaseModel): id: int user_id: int @@ -30,5 +45,8 @@ class NoteResponse(BaseModel): created_at: datetime updated_at: datetime + # ✅ 추가: 이 노트에 첨부된 파일들 + files: List[NoteFile] = [] + class Config: from_attributes = True