✨ 새로운 기능: - 사용자별 세분화된 권한 체크 (can_manage_books, can_manage_notes, can_manage_novels) - 페이지별 권한 가드 시스템 추가 (permission-guard.js) - 헤더 메뉴 권한별 표시/숨김 기능 🔧 백엔드 개선: - 모든 문서 관련 API에서 can_manage_books 권한 체크 추가 - documents.py: 개별 문서 조회, PDF 조회 권한 로직 수정 - highlights.py: 하이라이트 생성/조회 권한 체크 개선 - bookmarks.py: 북마크 생성/조회 권한 체크 개선 - document_links.py: 문서 링크 관련 권한 체크 개선 🎨 프론트엔드 개선: - header-loader.js: updateMenuPermissions 함수 추가로 권한별 메뉴 제어 - permission-guard.js: 페이지 접근 권한 체크 및 리다이렉트 처리 - 권한 없는 페이지 접근 시 메인 페이지로 안전한 리다이렉트 - 헤더 사용자 정보 상태 보존 로직 추가 🛡️ 보안 강화: - 403 Forbidden 에러 해결 - 권한 없는 사용자의 무단 페이지 접근 차단 - 문서 관리 권한이 있는 사용자는 모든 문서 공유 가능 📱 사용자 경험 개선: - 권한에 따른 메뉴 자동 표시/숨김 - 로그인 상태 유지 개선 - 권한 없는 기능 접근 시 친화적인 알림 및 리다이렉트
691 lines
28 KiB
Python
691 lines
28 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]
|
|
target_content_type: Optional[str] = "document" # "document" 또는 "note"
|
|
|
|
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 (current_user.is_admin or current_user.can_manage_books) and not source_doc.is_public and source_doc.uploaded_by != current_user.id:
|
|
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:
|
|
# 문서에서 찾지 못하면 노트에서 찾기
|
|
print(f"🔍 문서에서 찾지 못함, 노트에서 검색: {link_data.target_document_id}")
|
|
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 target_note:
|
|
print(f"✅ 노트 찾음: {target_note.title}")
|
|
else:
|
|
print(f"❌ 노트도 찾지 못함: {link_data.target_document_id}")
|
|
# 디버깅: 실제 존재하는 노트들 확인
|
|
all_notes_result = await db.execute(select(NoteDocument).limit(5))
|
|
all_notes = all_notes_result.scalars().all()
|
|
print(f"🔍 존재하는 노트 예시 (최대 5개):")
|
|
for note in all_notes:
|
|
print(f" - ID: {note.id}, 제목: {note.title}")
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Target document or note not found"
|
|
)
|
|
|
|
# 대상 문서/노트 권한 확인
|
|
if target_doc:
|
|
if not (current_user.is_admin or current_user.can_manage_books) and not target_doc.is_public and target_doc.uploaded_by != current_user.id:
|
|
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 (current_user.is_admin or current_user.can_manage_books) and not document.is_public and document.uploaded_by != current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Access denied"
|
|
)
|
|
|
|
# 모든 링크 조회 (문서→문서 + 문서→노트)
|
|
result = await db.execute(
|
|
select(DocumentLink)
|
|
.where(DocumentLink.source_document_id == document_id)
|
|
.order_by(DocumentLink.start_offset.asc())
|
|
)
|
|
|
|
all_links = result.scalars().all()
|
|
print(f"🔍 문서 링크 조회 완료: {len(all_links)}개 발견")
|
|
|
|
# 응답 데이터 구성
|
|
response_links = []
|
|
for link in all_links:
|
|
print(f"🔗 링크 처리 중: {link.id} -> {link.target_document_id}")
|
|
|
|
# 대상이 문서인지 노트인지 확인
|
|
target_doc = None
|
|
target_note = None
|
|
|
|
# 먼저 Document 테이블에서 찾기
|
|
doc_result = await db.execute(select(Document).where(Document.id == link.target_document_id))
|
|
target_doc = doc_result.scalar_one_or_none()
|
|
|
|
if target_doc:
|
|
print(f"✅ 대상 문서 찾음: {target_doc.title}")
|
|
target_title = target_doc.title
|
|
target_book_id = str(target_doc.book_id) if target_doc.book_id else None
|
|
target_content_type = "document"
|
|
else:
|
|
# Document에서 찾지 못하면 NoteDocument에서 찾기
|
|
from ...models.note_document import NoteDocument
|
|
note_result = await db.execute(select(NoteDocument).where(NoteDocument.id == link.target_document_id))
|
|
target_note = note_result.scalar_one_or_none()
|
|
|
|
if target_note:
|
|
print(f"✅ 대상 노트 찾음: {target_note.title}")
|
|
target_title = f"📝 {target_note.title}" # 노트임을 표시
|
|
target_book_id = str(target_note.notebook_id) if target_note.notebook_id else None
|
|
target_content_type = "note"
|
|
else:
|
|
print(f"❌ 대상을 찾을 수 없음: {link.target_document_id}")
|
|
target_title = "Unknown Target"
|
|
target_book_id = None
|
|
target_content_type = "document" # 기본값
|
|
|
|
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_title,
|
|
target_document_book_id=target_book_id,
|
|
target_content_type=target_content_type
|
|
))
|
|
|
|
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) | (current_user.can_manage_books == 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 (current_user.is_admin or current_user.can_manage_books) and not document.is_public and document.uploaded_by != current_user.id:
|
|
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) | (current_user.can_manage_books == 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 (current_user.is_admin or current_user.can_manage_books) and not document.is_public and document.uploaded_by != current_user.id:
|
|
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) | (current_user.can_manage_books == 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
|