Skip to content

Commit 084cd9a

Browse files
authored
Merge pull request #60 from Dynamite2003/release0.2
rebase: a basic version 0.2
2 parents b7168d2 + d1defa6 commit 084cd9a

32 files changed

Lines changed: 1013 additions & 248 deletions

backend/app/api/v1/endpoints/library.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
ensure_unique_storage_name,
2626
normalize_original_filename,
2727
sanitize_storage_filename,
28+
build_storage_name_with_email,
2829
)
2930

3031
settings = get_settings()
@@ -290,6 +291,7 @@ async def ensure_uploaded_paper_local(
290291
content = await _download_pdf_from_url(candidate_url)
291292
stored_filename, file_url, file_size, file_hash = await _save_pdf_bytes(
292293
current_user.id,
294+
current_user.email,
293295
content,
294296
preferred_name=record.original_filename,
295297
)
@@ -375,12 +377,14 @@ async def _download_pdf_from_url(url: str) -> bytes:
375377

376378
async def _save_pdf_bytes(
377379
user_id: int,
380+
user_email: str,
378381
content: bytes,
379382
*,
380383
preferred_name: str | None = None,
381384
) -> tuple[str, str, int, str]:
382385
display_name = normalize_original_filename(preferred_name or f"user_{user_id}.pdf")
383-
storage_candidate = sanitize_storage_filename(display_name)
386+
storage_candidate = build_storage_name_with_email(display_name, user_email)
387+
storage_candidate = sanitize_storage_filename(storage_candidate)
384388
stored_filename, destination = ensure_unique_storage_name(UPLOAD_DIR, storage_candidate)
385389
await asyncio.to_thread(destination.write_bytes, content)
386390
file_url = f"/media/uploads/{stored_filename}"

backend/app/api/v1/endpoints/papers.py

Lines changed: 103 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
ParsedPaperCacheRepository,
2828
UploadedPaperRepository,
2929
)
30+
from app.db.note_repository import NoteRepository
3031
from app.db.conversation_repository import ConversationRepository
3132
from app.db.session import get_db
3233
from app.dependencies.auth import get_current_user
@@ -46,6 +47,7 @@
4647
ensure_unique_storage_name,
4748
normalize_original_filename,
4849
sanitize_storage_filename,
50+
build_storage_name_with_email,
4951
)
5052

5153
if TYPE_CHECKING:
@@ -151,6 +153,8 @@ async def list_uploaded_papers(
151153
async def upload_paper(
152154
file: UploadFile = File(..., description="需要上传的 PDF 文件"),
153155
folder_id: int | None = Form(None, description="文件夹 ID,不填则保存在未分类"),
156+
conflict_resolution: str | None = Form(None, description="冲突处理方式:overwrite 或 rename"),
157+
new_filename: str | None = Form(None, description="当 conflict_resolution=rename 时的新文件名"),
154158
current_user=Depends(get_current_user),
155159
db: AsyncSession = Depends(get_db),
156160
) -> upload_schema.UploadedPaperRead:
@@ -170,42 +174,108 @@ async def upload_paper(
170174
)
171175

172176
cleaned_bytes = raw_bytes[:MAX_UPLOAD_BYTES]
173-
file_hash = _calculate_file_hash(cleaned_bytes)
174-
175-
original_display_name = normalize_original_filename(file.filename)
176-
storage_candidate = sanitize_storage_filename(original_display_name)
177-
stored_filename, destination = ensure_unique_storage_name(UPLOAD_DIR, storage_candidate)
178-
179-
await asyncio.to_thread(destination.write_bytes, cleaned_bytes)
180177

181-
relative_url = f"/media/uploads/{stored_filename}"
182-
183-
folder_repo = LibraryFolderRepository(db)
184178
repo = UploadedPaperRepository(db)
179+
folder_repo = LibraryFolderRepository(db)
185180
resolved_folder_id = await _ensure_folder_access(
186181
folder_repo,
187182
folder_id=folder_id if folder_id and folder_id > 0 else None,
188183
user_id=current_user.id,
189184
)
185+
186+
target_display_name = normalize_original_filename(file.filename)
187+
existing = await repo.get_by_original_name(current_user.id, target_display_name)
188+
189+
# 解析冲突处理策略
190+
resolution = (conflict_resolution or "").strip().lower() or None
191+
if existing and not resolution:
192+
raise HTTPException(
193+
status_code=status.HTTP_409_CONFLICT,
194+
detail={
195+
"message": "当前用户已存在同名文件",
196+
"conflict": True,
197+
"filename": target_display_name,
198+
"options": ["overwrite", "rename"],
199+
},
200+
)
201+
202+
if resolution == "rename":
203+
if not new_filename:
204+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重命名上传时必须提供新文件名")
205+
target_display_name = normalize_original_filename(new_filename)
206+
existing = await repo.get_by_original_name(current_user.id, target_display_name)
207+
if existing:
208+
raise HTTPException(
209+
status_code=status.HTTP_409_CONFLICT,
210+
detail={
211+
"message": "新的文件名仍然存在冲突,请更换名称",
212+
"conflict": True,
213+
"filename": target_display_name,
214+
"options": ["overwrite", "rename"],
215+
},
216+
)
217+
elif resolution not in {None, "overwrite"}:
218+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="无效的冲突处理方式")
219+
220+
if existing and resolution == "overwrite":
221+
stored_filename = existing.stored_filename
222+
destination = (UPLOAD_DIR / stored_filename).resolve()
223+
relative_url = existing.file_url or f"/media/uploads/{stored_filename}"
224+
# 先删除旧文件,再写入新内容,保持物理名不变
225+
try:
226+
if destination.exists():
227+
await asyncio.to_thread(destination.unlink)
228+
except Exception:
229+
logger.warning("Failed to remove existing file before overwrite: %s", destination)
230+
else:
231+
storage_candidate = build_storage_name_with_email(target_display_name, current_user.email)
232+
storage_candidate = sanitize_storage_filename(storage_candidate)
233+
stored_filename, destination = ensure_unique_storage_name(UPLOAD_DIR, storage_candidate)
234+
relative_url = f"/media/uploads/{stored_filename}"
235+
236+
await asyncio.to_thread(destination.write_bytes, cleaned_bytes)
237+
file_hash = _calculate_file_hash(cleaned_bytes)
238+
190239
metadata_json: dict | None = None
191240
try:
192-
metadata_json = await extract_pdf_metadata_async(destination, original_display_name)
241+
metadata_json = await extract_pdf_metadata_async(destination, target_display_name)
193242
except Exception as exc: # pragma: no cover - best effort only
194243
logger.warning("Failed to extract metadata for uploaded PDF: %s", exc)
195244
metadata_json = None
196245

197246
try:
198-
record = await repo.create(
199-
user_id=current_user.id,
200-
stored_filename=stored_filename,
201-
original_filename=original_display_name,
202-
content_type=file.content_type or "application/pdf",
203-
file_size=len(cleaned_bytes),
204-
file_url=relative_url,
205-
file_hash=file_hash,
206-
folder_id=resolved_folder_id,
207-
metadata_json=metadata_json,
208-
)
247+
if existing and resolution == "overwrite":
248+
await repo.purge_cached_artifacts(existing)
249+
250+
conv_repo = ConversationRepository(db)
251+
note_repo = NoteRepository(db)
252+
await conv_repo.delete_conversations_for_paper(current_user.id, existing.id)
253+
await note_repo.detach_uploaded_paper(current_user.id, existing.id)
254+
255+
# 更新记录为新的文件
256+
await repo.update_file_fields(
257+
existing,
258+
stored_filename=stored_filename,
259+
file_url=relative_url,
260+
file_size=len(cleaned_bytes),
261+
file_hash=file_hash,
262+
content_type=file.content_type or "application/pdf",
263+
)
264+
await repo.update_metadata(existing, metadata_json)
265+
record = existing
266+
else:
267+
# 新建记录(无冲突或重命名)
268+
record = await repo.create(
269+
user_id=current_user.id,
270+
stored_filename=stored_filename,
271+
original_filename=target_display_name,
272+
content_type=file.content_type or "application/pdf",
273+
file_size=len(cleaned_bytes),
274+
file_url=relative_url,
275+
file_hash=file_hash,
276+
folder_id=resolved_folder_id,
277+
metadata_json=metadata_json,
278+
)
209279
await db.commit()
210280
except Exception:
211281
await db.rollback()
@@ -834,21 +904,29 @@ async def _handle_paper_qa(
834904
conversation_id = request.conversation_id
835905

836906
if conversation_id:
837-
# 验证对话存在且属于当前用户,并属于智能阅读
907+
# 验证对话存在且属于当前用户,并属于智能阅读,且绑定到该文档
838908
conversation = await conv_repo.get_conversation(conversation_id, current_user.id)
839909
if not conversation or conversation.category != "reading":
840910
raise HTTPException(
841911
status_code=status.HTTP_404_NOT_FOUND,
842912
detail="Conversation not found or access denied"
843913
)
914+
if conversation.paper_id not in (None, request.paper_id):
915+
raise HTTPException(
916+
status_code=status.HTTP_404_NOT_FOUND,
917+
detail="Conversation not found for this paper",
918+
)
919+
if conversation.paper_id is None:
920+
conversation.paper_id = request.paper_id
921+
await db.flush()
844922
# 获取历史消息
845923
history_messages = await conv_repo.get_conversation_messages(conversation_id, current_user.id)
846924
else:
847-
# 创建新对话
925+
# 创建新对话并绑定该文档
848926
paper_title = parse_result.get("metadata", {}).get("title", "未命名文档")
849927
conversation = await conv_repo.create_conversation(
850928
current_user.id,
851-
ConversationCreate(title=f"关于《{paper_title}》的讨论", category="reading")
929+
ConversationCreate(title=f"关于《{paper_title}》的讨论", category="reading", paper_id=request.paper_id)
852930
)
853931
conversation_id = conversation.id
854932
history_messages = []

backend/app/api/v1/endpoints/users.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,20 @@
22
from __future__ import annotations
33

44
import asyncio
5+
import shutil
56
from pathlib import Path
67
from typing import Final
78
from uuid import uuid4
89

910
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
11+
from sqlalchemy import delete, select
1012
from sqlalchemy.ext.asyncio import AsyncSession
1113

1214
from app.core.config import get_settings
1315
from app.core.security import hash_password, verify_password
1416
from app.db.repository import UserRepository
17+
from app.models.uploaded_paper import UploadedPaper
18+
from app.models.parsed_paper_cache import ParsedPaperCache
1519
from app.db.session import get_db
1620
from app.dependencies.auth import get_current_user
1721
from app.schemas import user as user_schema
@@ -45,6 +49,16 @@ def _remove_avatar_file(avatar_url: str | None) -> None:
4549
pass
4650

4751

52+
def _remove_path_safely(target: Path) -> None:
53+
try:
54+
if target.is_file() or target.is_symlink():
55+
target.unlink()
56+
elif target.is_dir():
57+
shutil.rmtree(target)
58+
except OSError:
59+
pass
60+
61+
4862
@router.post(
4963
"",
5064
response_model=user_schema.UserRead,
@@ -179,13 +193,35 @@ async def delete_account(
179193
current_user=Depends(get_current_user),
180194
db: AsyncSession = Depends(get_db),
181195
):
182-
"""Soft-delete the user account by marking it inactive."""
196+
"""Hard delete user and all related data/files so the email can be reused."""
197+
198+
# Collect uploaded papers before DB deletion (to remove files/cache/parsed dirs)
199+
result = await db.execute(
200+
select(UploadedPaper.id, UploadedPaper.stored_filename, UploadedPaper.file_hash).where(
201+
UploadedPaper.user_id == current_user.id
202+
)
203+
)
204+
uploads = list(result.all())
183205

206+
# Remove physical files and parsed outputs
207+
for paper_id, stored_filename, file_hash in uploads:
208+
upload_path = settings.media_path / "uploads" / stored_filename
209+
parse_dir = settings.media_path / "parsed" / f"paper_{paper_id}"
210+
_remove_path_safely(upload_path)
211+
_remove_path_safely(parse_dir)
212+
213+
if file_hash:
214+
await db.execute(delete(ParsedPaperCache).where(ParsedPaperCache.file_hash == file_hash))
215+
216+
# Remove avatar if under media
184217
_remove_avatar_file(getattr(current_user, "avatar_url", None))
185218

186-
repo = UserRepository(db)
187-
await repo.update(current_user, {"is_active": False})
219+
# 删除用户前先删除上传记录,避免 ORM 删除流程尝试将 user_id 置空导致约束错误
220+
await db.execute(delete(UploadedPaper).where(UploadedPaper.user_id == current_user.id))
221+
222+
# Finally delete the user (FK cascades will clean remaining dependencies)
223+
await db.delete(current_user)
188224

189225
await db.commit()
190226

191-
return {"message": "账户已成功注销"}
227+
return {"message": "账户已彻底删除,可使用该邮箱重新注册"}

backend/app/db/conversation_repository.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,14 @@ async def create_conversation(self, user_id: int, data: ConversationCreate) -> C
2626
user_id=user_id,
2727
title=data.title,
2828
category=data.category or "search",
29+
paper_id=getattr(data, "paper_id", None),
2930
)
3031
self.db.add(conversation)
3132
await self.db.commit()
3233
await self.db.refresh(conversation)
3334
return conversation
3435

35-
async def get_conversation(self, conversation_id: int, user_id: int) -> Optional[Conversation]:
36+
async def get_conversation(self, conversation_id: int, user_id: int, *, paper_id: int | None = None) -> Optional[Conversation]:
3637
"""获取特定对话(含消息)"""
3738
stmt = (
3839
select(Conversation)
@@ -43,6 +44,8 @@ async def get_conversation(self, conversation_id: int, user_id: int) -> Optional
4344
)
4445
.options(selectinload(Conversation.messages))
4546
)
47+
if paper_id is not None:
48+
stmt = stmt.where(Conversation.paper_id == paper_id)
4649
result = await self.db.execute(stmt)
4750
return result.scalar_one_or_none()
4851

@@ -111,6 +114,28 @@ async def delete_conversation(self, conversation_id: int, user_id: int) -> bool:
111114
await self.db.commit()
112115
return True
113116

117+
async def delete_conversations_for_paper(self, user_id: int, paper_id: int) -> int:
118+
"""软删除绑定到指定文档的阅读类对话,返回删除数量"""
119+
stmt = (
120+
select(Conversation)
121+
.where(
122+
Conversation.user_id == user_id,
123+
Conversation.paper_id == paper_id,
124+
Conversation.category == "reading",
125+
Conversation.is_deleted == False,
126+
)
127+
.options(selectinload(Conversation.messages))
128+
)
129+
result = await self.db.execute(stmt)
130+
conversations = result.scalars().all()
131+
deleted = 0
132+
for conv in conversations:
133+
conv.is_deleted = True
134+
deleted += 1
135+
if deleted:
136+
await self.db.commit()
137+
return deleted
138+
114139
async def add_message(
115140
self,
116141
conversation_id: int,

backend/app/db/note_repository.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from collections.abc import Mapping
55
from typing import Any
66

7-
from sqlalchemy import func, select
7+
from sqlalchemy import func, select, update
88
from sqlalchemy.ext.asyncio import AsyncSession
99

1010
from app.models.note import Note
@@ -85,3 +85,16 @@ async def update(self, note: Note, updates: Mapping[str, Any]) -> Note:
8585
async def delete(self, note: Note) -> None:
8686
await self._session.delete(note)
8787
await self._session.flush()
88+
89+
async def detach_uploaded_paper(self, user_id: int, paper_id: int) -> int:
90+
"""Set uploaded_paper_id to NULL for notes linked to the given paper, returns affected rows."""
91+
92+
stmt = (
93+
update(Note)
94+
.where(Note.user_id == user_id, Note.uploaded_paper_id == paper_id)
95+
.values(uploaded_paper_id=None)
96+
.execution_options(synchronize_session="fetch")
97+
)
98+
result = await self._session.execute(stmt)
99+
await self._session.flush()
100+
return result.rowcount or 0

0 commit comments

Comments
 (0)