Files
document-server/backend/src/api/routes/document_links.py
Hyungi Ahn d74cb070ca Fix: 문서-노트 간 링크 생성 및 링크 삭제 기능 수정
- 문서에서 노트로의 링크 생성 지원 추가
- 대상이 문서가 아닌 경우 NoteDocument 테이블에서 검색
- 노트 권한 확인 로직 추가
- 링크 삭제 엔드포인트에 /document-links/{link_id} 경로 추가 (프론트엔드 호환성)
- 응답에서 노트 제목과 노트북 ID 지원
2025-09-03 18:46:11 +09:00

646 lines
25 KiB
Python

"""
문서 링크 관련 API 엔드포인트
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from typing import List, Optional
from pydantic import BaseModel
import uuid
from ...core.database import get_db
from ..dependencies import get_current_active_user
from ...models import User, Document, DocumentLink
router = APIRouter()
# Pydantic 모델들
class DocumentLinkCreate(BaseModel):
target_document_id: str
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str] = None
description: Optional[str] = None
# 고급 링크 기능 (모두 Optional로 설정)
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = "document" # "document" or "text_fragment"
class DocumentLinkUpdate(BaseModel):
target_document_id: Optional[str] = None
link_text: Optional[str] = None
description: Optional[str] = None
# 고급 링크 기능
target_text: Optional[str] = None
target_start_offset: Optional[int] = None
target_end_offset: Optional[int] = None
link_type: Optional[str] = None
class DocumentLinkResponse(BaseModel):
id: str
source_document_id: str
target_document_id: str
selected_text: str
start_offset: int
end_offset: int
link_text: Optional[str]
description: Optional[str]
created_at: str
updated_at: Optional[str]
# 고급 링크 기능
target_text: Optional[str]
target_start_offset: Optional[int]
target_end_offset: Optional[int]
link_type: Optional[str] = "document"
# 대상 문서 정보
target_document_title: str
target_document_book_id: Optional[str]
class Config:
from_attributes = True
class LinkableDocumentResponse(BaseModel):
id: str
title: str
book_id: Optional[str]
book_title: Optional[str]
sort_order: int
class Config:
from_attributes = True
@router.post("/{document_id}/links", response_model=DocumentLinkResponse)
async def create_document_link(
document_id: str,
link_data: DocumentLinkCreate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 생성"""
print(f"🔗 링크 생성 요청 - 문서 ID: {document_id}")
print(f"📋 링크 데이터: {link_data}")
print(f"🎯 target_text: '{link_data.target_text}'")
print(f"🎯 target_start_offset: {link_data.target_start_offset}")
print(f"🎯 target_end_offset: {link_data.target_end_offset}")
print(f"🎯 link_type: {link_data.link_type}")
if link_data.link_type == 'text_fragment' and not link_data.target_text:
print("🚨 CRITICAL: text_fragment 링크인데 target_text가 없습니다!")
# 출발 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
source_doc = result.scalar_one_or_none()
if not source_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Source document not found"
)
# 권한 확인
if not source_doc.is_public and source_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to source document"
)
# 대상 문서 또는 노트 확인
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
target_doc = result.scalar_one_or_none()
target_note = None
if not target_doc:
# 문서에서 찾지 못하면 노트에서 찾기
from models.note_document import NoteDocument
result = await db.execute(select(NoteDocument).where(NoteDocument.id == link_data.target_document_id))
target_note = result.scalar_one_or_none()
if not target_note:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Target document or note not found"
)
# 대상 문서/노트 권한 확인
if target_doc:
if not target_doc.is_public and target_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to target document"
)
# HTML 문서만 링크 가능
if not target_doc.html_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only link to HTML documents"
)
elif target_note:
# 노트 권한 확인 (노트는 기본적으로 생성자만 접근 가능)
if target_note.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to target note"
)
# 링크 생성
new_link = DocumentLink(
source_document_id=uuid.UUID(document_id),
target_document_id=uuid.UUID(link_data.target_document_id),
selected_text=link_data.selected_text,
start_offset=link_data.start_offset,
end_offset=link_data.end_offset,
link_text=link_data.link_text,
description=link_data.description,
# 고급 링크 기능
target_text=link_data.target_text,
target_start_offset=link_data.target_start_offset,
target_end_offset=link_data.target_end_offset,
link_type=link_data.link_type,
created_by=current_user.id
)
db.add(new_link)
await db.commit()
await db.refresh(new_link)
target_title = target_doc.title if target_doc else target_note.title
target_type = "document" if target_doc else "note"
print(f"✅ 링크 생성 완료: {source_doc.title} -> {target_title} ({target_type})")
print(f" - 링크 타입: {new_link.link_type}")
print(f" - 선택된 텍스트: {new_link.selected_text}")
print(f" - 대상 텍스트: {new_link.target_text}")
# 백링크는 자동으로 생성되지 않음 - 기존 링크를 역방향으로 조회하는 방식 사용
# 응답 데이터 구성
return DocumentLinkResponse(
id=str(new_link.id),
source_document_id=str(new_link.source_document_id),
target_document_id=str(new_link.target_document_id),
selected_text=new_link.selected_text,
start_offset=new_link.start_offset,
end_offset=new_link.end_offset,
link_text=new_link.link_text,
description=new_link.description,
# 고급 링크 기능
target_text=new_link.target_text,
target_start_offset=new_link.target_start_offset,
target_end_offset=new_link.target_end_offset,
link_type=new_link.link_type,
created_at=new_link.created_at.isoformat(),
updated_at=new_link.updated_at.isoformat() if new_link.updated_at else None,
target_document_title=target_title,
target_document_book_id=str(target_doc.book_id) if target_doc and target_doc.book_id else (str(target_note.notebook_id) if target_note and target_note.notebook_id else None)
)
@router.get("/{document_id}/links", response_model=List[DocumentLinkResponse])
async def get_document_links(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서의 모든 링크 조회"""
# 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 링크 조회 (JOIN으로 대상 문서 정보도 함께)
result = await db.execute(
select(DocumentLink, Document)
.join(Document, DocumentLink.target_document_id == Document.id)
.where(DocumentLink.source_document_id == document_id)
.order_by(DocumentLink.start_offset.asc())
)
links_with_targets = result.all()
# 응답 데이터 구성
response_links = []
for link, target_doc in links_with_targets:
response_links.append(DocumentLinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
target_document_id=str(link.target_document_id),
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
created_at=link.created_at.isoformat(),
updated_at=link.updated_at.isoformat() if link.updated_at else None,
# 고급 링크 기능 (기존 링크는 None일 수 있음)
target_text=getattr(link, 'target_text', None),
target_start_offset=getattr(link, 'target_start_offset', None),
target_end_offset=getattr(link, 'target_end_offset', None),
link_type=getattr(link, 'link_type', 'document'),
# 대상 문서 정보
target_document_title=target_doc.title,
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None
))
return response_links
@router.get("/{document_id}/linkable-documents", response_model=List[LinkableDocumentResponse])
async def get_linkable_documents(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""링크 가능한 문서 목록 조회 (같은 서적 우선, 전체 HTML 문서)"""
# 현재 문서 확인
result = await db.execute(select(Document).where(Document.id == document_id))
current_doc = result.scalar_one_or_none()
if not current_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not current_doc.is_public and current_doc.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 링크 가능한 HTML 문서들 조회
# 1. 같은 서적의 문서들 (우선순위)
# 2. 다른 서적의 문서들
from ...models import Book
query = select(Document, Book).outerjoin(Book, Document.book_id == Book.id).where(
and_(
Document.html_path.isnot(None), # HTML 문서만
Document.id != document_id, # 자기 자신 제외
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(
# 같은 서적 우선, 그 다음 정렬 순서
(Document.book_id == current_doc.book_id).desc(),
Document.sort_order.asc().nulls_last(),
Document.created_at.asc()
)
result = await db.execute(query)
documents_with_books = result.all()
# 응답 데이터 구성
linkable_docs = []
for doc, book in documents_with_books:
linkable_docs.append(LinkableDocumentResponse(
id=str(doc.id),
title=doc.title,
book_id=str(doc.book_id) if doc.book_id else None,
book_title=book.title if book else None,
sort_order=doc.sort_order or 0
))
return linkable_docs
@router.put("/links/{link_id}", response_model=DocumentLinkResponse)
async def update_document_link(
link_id: str,
link_data: DocumentLinkUpdate,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 수정"""
# 링크 조회
result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id))
link = result.scalar_one_or_none()
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Link not found"
)
# 권한 확인 (생성자만 수정 가능)
if link.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 대상 문서 변경 시 검증
if link_data.target_document_id:
result = await db.execute(select(Document).where(Document.id == link_data.target_document_id))
target_doc = result.scalar_one_or_none()
if not target_doc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Target document not found"
)
if not target_doc.html_path:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only link to HTML documents"
)
link.target_document_id = uuid.UUID(link_data.target_document_id)
# 필드 업데이트
if link_data.link_text is not None:
link.link_text = link_data.link_text
if link_data.description is not None:
link.description = link_data.description
await db.commit()
await db.refresh(link)
# 대상 문서 정보 조회
result = await db.execute(select(Document).where(Document.id == link.target_document_id))
target_doc = result.scalar_one()
return DocumentLinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
target_document_id=str(link.target_document_id),
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
created_at=link.created_at.isoformat(),
updated_at=link.updated_at.isoformat() if link.updated_at else None,
target_document_title=target_doc.title,
target_document_book_id=str(target_doc.book_id) if target_doc.book_id else None
)
@router.delete("/links/{link_id}")
@router.delete("/document-links/{link_id}") # 프론트엔드 호환성을 위한 추가 경로
async def delete_document_link(
link_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 링크 삭제"""
# 링크 조회
result = await db.execute(select(DocumentLink).where(DocumentLink.id == link_id))
link = result.scalar_one_or_none()
if not link:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Link not found"
)
# 권한 확인 (생성자만 삭제 가능)
if link.created_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
await db.delete(link)
await db.commit()
return {"message": "Link deleted successfully"}
# 백링크 관련 모델
class BacklinkResponse(BaseModel):
id: str
source_document_id: str
source_document_title: str
source_document_book_id: Optional[str]
source_content_type: Optional[str] = "document" # "document" or "note"
target_document_id: str
target_document_title: str
selected_text: str # 소스 문서에서 선택한 텍스트
start_offset: int # 소스 문서 오프셋
end_offset: int # 소스 문서 오프셋
link_text: Optional[str]
description: Optional[str]
link_type: str
target_text: Optional[str] # 🎯 타겟 문서의 텍스트 (백링크 렌더링용)
target_start_offset: Optional[int] # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
target_end_offset: Optional[int] # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
created_at: str
class Config:
from_attributes = True
@router.get("/{document_id}/backlinks", response_model=List[BacklinkResponse])
async def get_document_backlinks(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서의 백링크 조회 (이 문서를 참조하는 모든 링크)"""
print(f"🔍 백링크 API 호출됨 - 문서 ID: {document_id}, 사용자: {current_user.email}")
# 문서 존재 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
print(f"❌ 문서를 찾을 수 없음: {document_id}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
print(f"✅ 문서 찾음: {document.title}")
# 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 이 문서를 대상으로 하는 모든 링크 조회 (백링크)
from ...models import Book
from ...models.note_link import NoteLink
from ...models.note_document import NoteDocument
from ...models.notebook import Notebook
# 1. 일반 문서에서 오는 백링크 (DocumentLink)
doc_query = select(DocumentLink, Document, Book).join(
Document, DocumentLink.source_document_id == Document.id
).outerjoin(Book, Document.book_id == Book.id).where(
and_(
DocumentLink.target_document_id == document_id,
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(DocumentLink.created_at.desc())
doc_result = await db.execute(doc_query)
backlinks = []
print(f"🔍 문서 백링크 쿼리 실행 완료")
# 일반 문서 백링크 처리
for link, source_doc, book in doc_result.fetchall():
print(f"📋 백링크 발견: {source_doc.title} -> {document.title}")
print(f" - 소스 텍스트 (selected_text): {link.selected_text}")
print(f" - 타겟 텍스트 (target_text): {link.target_text}")
print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}")
print(f" - 링크 타입: {link.link_type}")
backlinks.append(BacklinkResponse(
id=str(link.id),
source_document_id=str(link.source_document_id),
source_document_title=source_doc.title,
source_document_book_id=str(book.id) if book else None,
source_content_type="document", # 일반 문서
target_document_id=str(link.target_document_id),
target_document_title=document.title,
selected_text=link.selected_text, # 소스 문서에서 선택한 텍스트 (참고용)
start_offset=link.start_offset, # 소스 문서 오프셋 (참고용)
end_offset=link.end_offset, # 소스 문서 오프셋 (참고용)
link_text=link.link_text,
description=link.description,
link_type=link.link_type,
target_text=link.target_text, # 🎯 타겟 문서의 텍스트 (백링크 렌더링용)
target_start_offset=link.target_start_offset, # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
target_end_offset=link.target_end_offset, # 🎯 타겟 문서 오프셋 (백링크 렌더링용)
created_at=link.created_at.isoformat()
))
# 2. 노트에서 오는 백링크 (NoteLink) - 동기 쿼리 사용
try:
from ...core.database import get_sync_db
sync_db = next(get_sync_db())
# 노트에서 이 문서를 대상으로 하는 링크들 조회
note_links = sync_db.query(NoteLink).join(
NoteDocument, NoteLink.source_note_id == NoteDocument.id
).outerjoin(Notebook, NoteDocument.notebook_id == Notebook.id).filter(
NoteLink.target_document_id == document_id
).all()
print(f"🔍 노트 백링크 쿼리 실행 완료: {len(note_links)}개 발견")
# 노트 백링크 처리
for link in note_links:
source_note = sync_db.query(NoteDocument).filter(NoteDocument.id == link.source_note_id).first()
notebook = sync_db.query(Notebook).filter(Notebook.id == source_note.notebook_id).first() if source_note else None
if source_note:
print(f"📋 노트 백링크 발견: {source_note.title} -> {document.title}")
print(f" - 소스 텍스트 (selected_text): {link.selected_text}")
print(f" - 타겟 텍스트 (target_text): {link.target_text}")
print(f" - 타겟 오프셋: {link.target_start_offset}-{link.target_end_offset}")
print(f" - 링크 타입: {link.link_type}")
backlinks.append(BacklinkResponse(
id=str(link.id),
source_document_id=str(link.source_note_id), # 노트 ID를 문서 ID로 사용
source_document_title=f"📝 {source_note.title}", # 노트임을 표시
source_document_book_id=str(notebook.id) if notebook else None,
source_content_type="note", # 노트 문서
target_document_id=str(link.target_document_id) if link.target_document_id else document_id,
target_document_title=document.title,
selected_text=link.selected_text,
start_offset=link.start_offset,
end_offset=link.end_offset,
link_text=link.link_text,
description=link.description,
link_type=link.link_type,
target_text=link.target_text,
target_start_offset=link.target_start_offset,
target_end_offset=link.target_end_offset,
created_at=link.created_at.isoformat() if link.created_at else None
))
sync_db.close()
except Exception as e:
print(f"❌ 노트 백링크 조회 실패: {e}")
print(f"✅ 총 {len(backlinks)}개의 백링크 반환 (문서 + 노트)")
return backlinks
@router.get("/{document_id}/link-fragments")
async def get_document_link_fragments(
document_id: str,
current_user: User = Depends(get_current_active_user),
db: AsyncSession = Depends(get_db)
):
"""문서 내 모든 링크된 텍스트 조각 조회 (중복 링크 관리용)"""
# 문서 존재 확인
result = await db.execute(select(Document).where(Document.id == document_id))
document = result.scalar_one_or_none()
if not document:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Document not found"
)
# 권한 확인
if not document.is_public and document.uploaded_by != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
# 이 문서에서 출발하는 모든 링크 조회
query = select(DocumentLink, Document).join(
Document, DocumentLink.target_document_id == Document.id
).where(
and_(
DocumentLink.source_document_id == document_id,
# 권한 확인: 공개 문서이거나 본인이 업로드한 문서
(Document.is_public == True) | (Document.uploaded_by == current_user.id) | (current_user.is_admin == True)
)
).order_by(DocumentLink.start_offset.asc())
result = await db.execute(query)
fragments = []
for link, target_doc in result.fetchall():
fragments.append({
"link_id": str(link.id),
"start_offset": link.start_offset,
"end_offset": link.end_offset,
"selected_text": link.selected_text,
"target_document_id": str(link.target_document_id),
"target_document_title": target_doc.title,
"link_text": link.link_text,
"description": link.description,
"link_type": link.link_type,
"target_text": link.target_text,
"target_start_offset": link.target_start_offset,
"target_end_offset": link.target_end_offset
})
return fragments