import base64 import json from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path from uuid import UUID from fastapi import Depends, FastAPI, HTTPException, Query, Response from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import delete, exists, func, or_, select, update from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from .auth import ( COOKIE_NAME, current_user, hash_password, hash_token, issue_session, session_token, verify_password, ) from .db import create_schema, get_db from .models import AppState, Folder, Session, Tag, Task, TaskList, TaskTag, User, utcnow from .schemas import ( BatchResult, BatchTaskUpdate, FolderCreate, FolderOut, InitializeRequest, ListCreate, ListOut, LoginRequest, NameUpdate, TagCreate, TagOut, TaskCreate, TaskDetailOut, TaskOut, TaskPage, TaskUpdate, UserOut, ) @asynccontextmanager async def lifespan(app: FastAPI): from .config import get_settings if get_settings().auto_create_schema: await create_schema() yield app = FastAPI( title="dodo", version="0.1.0", lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json", ) @app.get("/health/live") async def live(): return {"status": "ok"} @app.get("/health/ready") async def ready(db: AsyncSession = Depends(get_db)): await db.execute(select(1)) return {"status": "ok"} @app.get("/api/v1/setup/status") async def setup_status(db: AsyncSession = Depends(get_db)): count = await db.scalar(select(func.count()).select_from(User)) return {"initialized": bool(count)} @app.post("/api/v1/setup/initialize", response_model=UserOut, status_code=201) async def initialize( payload: InitializeRequest, response: Response, db: AsyncSession = Depends(get_db), ): count = await db.scalar(select(func.count()).select_from(User)) if count: raise HTTPException(status_code=409, detail="系统已经初始化") db.add(AppState(key="initialized")) user = User(username=payload.username, password_hash=hash_password(payload.password)) db.add(user) try: await db.flush() except IntegrityError as exc: await db.rollback() raise HTTPException(status_code=409, detail="系统已经初始化") from exc db.add(TaskList(user_id=user.id, name="收集箱", is_inbox=True)) await db.commit() await db.refresh(user) await issue_session(db, response, user) return user @app.post("/api/v1/auth/login", response_model=UserOut) async def login( payload: LoginRequest, response: Response, db: AsyncSession = Depends(get_db), ): user = await db.scalar(select(User).where(User.username == payload.username)) if user is None or not verify_password(user.password_hash, payload.password): raise HTTPException(status_code=401, detail="用户名或密码错误") await issue_session(db, response, user) return user @app.get("/api/v1/me", response_model=UserOut) async def me(user: User = Depends(current_user)): return user @app.post("/api/v1/auth/logout", status_code=204) async def logout( response: Response, token: str = Depends(session_token), db: AsyncSession = Depends(get_db), ): session = await db.scalar(select(Session).where(Session.token_hash == hash_token(token))) if session is not None: await db.delete(session) await db.commit() response.delete_cookie(COOKIE_NAME, path="/") return Response(status_code=204, headers=response.headers) @app.post("/api/v1/folders", response_model=FolderOut, status_code=201) async def create_folder( payload: FolderCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): folder = Folder(user_id=user.id, name=payload.name) db.add(folder) await db.commit() await db.refresh(folder) return folder @app.get("/api/v1/folders", response_model=list[FolderOut]) async def list_folders(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): query = select(Folder).where(Folder.user_id == user.id, Folder.deleted_at.is_(None)) return list((await db.scalars(query.order_by(Folder.position, Folder.created_at))).all()) @app.patch("/api/v1/folders/{folder_id}", response_model=FolderOut) async def rename_folder( folder_id: UUID, payload: NameUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): folder = await db.scalar( select(Folder).where( Folder.id == folder_id, Folder.user_id == user.id, Folder.deleted_at.is_(None) ) ) if folder is None: raise HTTPException(status_code=404, detail="文件夹不存在") folder.name = payload.name await db.commit() return folder @app.delete("/api/v1/folders/{folder_id}", status_code=204) async def delete_folder( folder_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): folder = await db.scalar( select(Folder).where( Folder.id == folder_id, Folder.user_id == user.id, Folder.deleted_at.is_(None) ) ) if folder is None: raise HTTPException(status_code=404, detail="文件夹不存在") folder.deleted_at = utcnow() await db.execute( update(TaskList) .where(TaskList.user_id == user.id, TaskList.folder_id == folder.id) .values(folder_id=None) ) await db.commit() return Response(status_code=204) @app.post("/api/v1/lists", response_model=ListOut, status_code=201) async def create_list( payload: ListCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): if payload.folder_id and not await db.scalar( select(Folder.id).where( Folder.id == payload.folder_id, Folder.user_id == user.id, Folder.deleted_at.is_(None), ) ): raise HTTPException(status_code=404, detail="文件夹不存在") item = TaskList(user_id=user.id, folder_id=payload.folder_id, name=payload.name) db.add(item) await db.commit() await db.refresh(item) return item @app.get("/api/v1/lists", response_model=list[ListOut]) async def list_lists(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): query = select(TaskList).where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None)) ordering = (TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at) return list((await db.scalars(query.order_by(*ordering))).all()) async def _owned_list(db: AsyncSession, user_id: UUID, list_id: UUID) -> TaskList: item = await db.scalar( select(TaskList).where( TaskList.id == list_id, TaskList.user_id == user_id, TaskList.deleted_at.is_(None), ) ) if item is None: raise HTTPException(status_code=404, detail="清单不存在") return item @app.patch("/api/v1/lists/{list_id}", response_model=ListOut) async def rename_list( list_id: UUID, payload: NameUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): item = await _owned_list(db, user.id, list_id) if item.is_inbox: raise HTTPException(status_code=409, detail="系统收集箱不能重命名") item.name = payload.name await db.commit() return item @app.delete("/api/v1/lists/{list_id}", status_code=204) async def delete_list( list_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): item = await _owned_list(db, user.id, list_id) if item.is_inbox: raise HTTPException(status_code=409, detail="系统收集箱不能删除") inbox_id = await db.scalar( select(TaskList.id).where( TaskList.user_id == user.id, TaskList.is_inbox.is_(True), TaskList.deleted_at.is_(None), ) ) if inbox_id is None: raise HTTPException(status_code=409, detail="系统收集箱不存在") await db.execute( update(Task) .where(Task.user_id == user.id, Task.list_id == item.id, Task.deleted_at.is_(None)) .values(list_id=inbox_id, version=Task.version + 1, updated_at=utcnow()) ) item.deleted_at = utcnow() await db.commit() return Response(status_code=204) @app.post("/api/v1/tags", response_model=TagOut, status_code=201) async def create_tag( payload: TagCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): existing = await db.scalar( select(Tag.id).where(Tag.user_id == user.id, func.lower(Tag.name) == payload.name.lower()) ) if existing: raise HTTPException(status_code=409, detail="标签名称已存在") tag = Tag(user_id=user.id, **payload.model_dump()) db.add(tag) await db.commit() await db.refresh(tag) return tag @app.get("/api/v1/tags", response_model=list[TagOut]) async def list_tags(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): return list( (await db.scalars(select(Tag).where(Tag.user_id == user.id).order_by(Tag.name, Tag.id))).all() ) async def _validate_tags( db: AsyncSession, user_id: UUID, tag_ids: list[UUID] | None ) -> list[UUID] | None: if tag_ids is None: return None unique_ids = list(dict.fromkeys(tag_ids)) if not unique_ids: return [] found = set( (await db.scalars(select(Tag.id).where(Tag.user_id == user_id, Tag.id.in_(unique_ids)))).all() ) if found != set(unique_ids): raise HTTPException(status_code=404, detail="标签不存在") return unique_ids async def _replace_tags(db: AsyncSession, task_ids: list[UUID], tag_ids: list[UUID]) -> None: await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(task_ids))) db.add_all(TaskTag(task_id=task_id, tag_id=tag_id) for task_id in task_ids for tag_id in tag_ids) def _encode_cursor(created_at: datetime, task_id: UUID) -> str: raw = json.dumps([created_at.isoformat(), str(task_id)]).encode() return base64.urlsafe_b64encode(raw).decode().rstrip("=") def _decode_cursor(cursor: str) -> tuple[datetime, UUID]: try: raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) timestamp, task_id = json.loads(raw) return datetime.fromisoformat(timestamp), UUID(task_id) except (ValueError, TypeError, json.JSONDecodeError) as exc: raise HTTPException(status_code=422, detail="无效的游标") from exc @app.post("/api/v1/tasks", response_model=TaskOut, status_code=201) async def create_task( payload: TaskCreate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): await _owned_list(db, user.id, payload.list_id) tag_ids = await _validate_tags(db, user.id, payload.tag_ids) if payload.parent_id: parent = await db.scalar( select(Task).where( Task.id == payload.parent_id, Task.user_id == user.id, Task.list_id == payload.list_id, Task.parent_id.is_(None), Task.deleted_at.is_(None), ) ) if parent is None: raise HTTPException(status_code=400, detail="父任务必须是同一清单的顶层任务") data = payload.model_dump(exclude={"tag_ids"}) task = Task(user_id=user.id, **data) db.add(task) await db.flush() if tag_ids: db.add_all(TaskTag(task_id=task.id, tag_id=tag_id) for tag_id in tag_ids) await db.commit() await db.refresh(task) return task @app.get("/api/v1/tasks", response_model=TaskPage) async def list_tasks( q: str | None = None, cursor: str | None = None, limit: int = Query(default=50, ge=1, le=100), user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): query = select(Task).where( Task.user_id == user.id, Task.deleted_at.is_(None), Task.parent_id.is_(None) ) if q: pattern = f"%{q}%" tag_match = exists( select(TaskTag.task_id) .join(Tag, Tag.id == TaskTag.tag_id) .where(TaskTag.task_id == Task.id, Tag.user_id == user.id, Tag.name.ilike(pattern)) ) list_match = exists( select(TaskList.id).where( TaskList.id == Task.list_id, TaskList.user_id == user.id, TaskList.name.ilike(pattern), ) ) query = query.where( or_(Task.title.ilike(pattern), Task.description.ilike(pattern), tag_match, list_match) ) if cursor: created_at, task_id = _decode_cursor(cursor) query = query.where( or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id)) ) rows = list((await db.scalars(query.order_by(Task.created_at, Task.id).limit(limit + 1))).all()) has_more = len(rows) > limit items = rows[:limit] next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None return TaskPage(items=items, next_cursor=next_cursor) async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut: tags = list( ( await db.scalars( select(Tag) .join(TaskTag, TaskTag.tag_id == Tag.id) .where(TaskTag.task_id == task.id) .order_by(Tag.name, Tag.id) ) ).all() ) subtasks = list( ( await db.scalars( select(Task) .where(Task.parent_id == task.id, Task.deleted_at.is_(None)) .order_by(Task.position, Task.created_at, Task.id) ) ).all() ) data = TaskOut.model_validate(task).model_dump() return TaskDetailOut(**data, tags=tags, subtasks=subtasks) @app.get("/api/v1/tasks/{task_id}", response_model=TaskDetailOut) async def get_task( task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): task = await db.scalar( select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)) ) if task is None: raise HTTPException(status_code=404, detail="任务不存在") return await _task_detail(db, task) @app.patch("/api/v1/tasks/{task_id}", response_model=TaskDetailOut) async def update_task( task_id: UUID, payload: TaskUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): data = payload.model_dump(exclude_unset=True) expected_version = data.pop("version") tag_ids = await _validate_tags(db, user.id, data.pop("tag_ids", None)) if "list_id" in data: await _owned_list(db, user.id, data["list_id"]) parent_id = await db.scalar( select(Task.parent_id).where(Task.id == task_id, Task.user_id == user.id) ) if parent_id: parent_list = await db.scalar( select(Task.list_id).where(Task.id == parent_id, Task.user_id == user.id) ) if parent_list != data["list_id"]: raise HTTPException(status_code=400, detail="子任务必须与父任务属于同一清单") data["version"] = Task.version + 1 data["updated_at"] = utcnow() result = await db.execute( update(Task) .where( Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None), Task.version == expected_version, ) .values(**data) .returning(Task) ) task = result.scalar_one_or_none() if task is None: exists_id = await db.scalar( select(Task.id).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)) ) if exists_id: raise HTTPException(status_code=409, detail="任务已被更新,请刷新后重试") raise HTTPException(status_code=404, detail="任务不存在") if tag_ids is not None: await _replace_tags(db, [task_id], tag_ids) if "list_id" in data and task.parent_id is None: await db.execute( update(Task) .where(Task.parent_id == task.id, Task.user_id == user.id, Task.deleted_at.is_(None)) .values(list_id=task.list_id, version=Task.version + 1, updated_at=utcnow()) ) await db.commit() return await _task_detail(db, task) @app.delete("/api/v1/tasks/{task_id}", status_code=204) async def delete_task( task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): task = await db.scalar( select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_(None)) ) if task is None: raise HTTPException(status_code=404, detail="任务不存在") deleted_at = utcnow() await db.execute( update(Task) .where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id) .values(deleted_at=deleted_at, version=Task.version + 1, updated_at=deleted_at) ) await db.commit() return Response(status_code=204) @app.get("/api/v1/trash", response_model=TaskPage) async def list_trash( cursor: str | None = None, limit: int = Query(default=50, ge=1, le=100), user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): query = select(Task).where( Task.user_id == user.id, Task.deleted_at.is_not(None), Task.parent_id.is_(None) ) if cursor: created_at, task_id = _decode_cursor(cursor) query = query.where( or_(Task.created_at > created_at, (Task.created_at == created_at) & (Task.id > task_id)) ) rows = list((await db.scalars(query.order_by(Task.created_at, Task.id).limit(limit + 1))).all()) has_more = len(rows) > limit items = rows[:limit] next_cursor = _encode_cursor(items[-1].created_at, items[-1].id) if has_more else None return TaskPage(items=items, next_cursor=next_cursor) @app.post("/api/v1/tasks/{task_id}/restore", response_model=TaskDetailOut) async def restore_task( task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): task = await db.scalar( select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_not(None)) ) if task is None: raise HTTPException(status_code=404, detail="回收站中不存在该任务") await _owned_list(db, user.id, task.list_id) now = utcnow() await db.execute( update(Task) .where(or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id) .values(deleted_at=None, version=Task.version + 1, updated_at=now) ) await db.commit() await db.refresh(task) return await _task_detail(db, task) @app.delete("/api/v1/trash/{task_id}", status_code=204) async def permanently_delete_task( task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): task = await db.scalar( select(Task).where(Task.id == task_id, Task.user_id == user.id, Task.deleted_at.is_not(None)) ) if task is None: raise HTTPException(status_code=404, detail="回收站中不存在该任务") ids = list( ( await db.scalars( select(Task.id).where( or_(Task.id == task.id, Task.parent_id == task.id), Task.user_id == user.id ) ) ).all() ) await db.execute(delete(TaskTag).where(TaskTag.task_id.in_(ids))) await db.execute(delete(Task).where(Task.parent_id == task.id, Task.user_id == user.id)) await db.delete(task) await db.commit() return Response(status_code=204) @app.post("/api/v1/tasks/batch", response_model=BatchResult) async def batch_update_tasks( payload: BatchTaskUpdate, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): task_ids = list(dict.fromkeys(payload.task_ids)) tasks = list( ( await db.scalars( select(Task).where( Task.id.in_(task_ids), Task.user_id == user.id, Task.deleted_at.is_(None) ) ) ).all() ) if len(tasks) != len(task_ids): raise HTTPException(status_code=404, detail="一个或多个任务不存在") if payload.list_id is not None: await _owned_list(db, user.id, payload.list_id) tag_ids = await _validate_tags(db, user.id, payload.tag_ids) changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "tag_ids", "soft_delete"}) if payload.soft_delete: changes["deleted_at"] = utcnow() if changes: changes["version"] = Task.version + 1 changes["updated_at"] = utcnow() await db.execute( update(Task) .where(Task.id.in_(task_ids), Task.user_id == user.id, Task.deleted_at.is_(None)) .values(**changes) ) if payload.list_id is not None: parent_ids = [task.id for task in tasks if task.parent_id is None] if parent_ids: await db.execute( update(Task) .where( Task.parent_id.in_(parent_ids), Task.user_id == user.id, Task.deleted_at.is_(None), ) .values( list_id=payload.list_id, version=Task.version + 1, updated_at=utcnow(), ) ) if tag_ids is not None: await _replace_tags(db, task_ids, tag_ids) await db.commit() return BatchResult(updated=len(task_ids)) static_dir = Path(__file__).parent / "static" if static_dir.exists(): app.mount("/assets", StaticFiles(directory=static_dir / "assets"), name="assets") @app.get("/{path:path}", include_in_schema=False) async def spa(path: str): root = static_dir.resolve() target = (root / path).resolve() if target.is_file() and target.is_relative_to(root): return FileResponse(target) return FileResponse(root / "index.html")