import base64 import json import time from collections import defaultdict, deque from contextlib import asynccontextmanager from datetime import datetime, timedelta from pathlib import Path from uuid import UUID from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response from fastapi.openapi.docs import get_swagger_ui_html from fastapi.responses import FileResponse, HTMLResponse, JSONResponse 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, CSRF_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, RecurrenceTemplate, Session, Task, TaskList, User, utcnow from .mvp import audit, occurrences from .mvp import router as mvp_router from .schemas import ( BatchResult, BatchTaskUpdate, ChangePasswordRequest, FolderCreate, FolderOut, InitializeRequest, ListCreate, ListOut, LoginRequest, NameUpdate, SessionOut, TaskCreate, TaskDetailOut, TaskOut, TaskPage, TaskReorder, 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.2.0", lifespan=lifespan, docs_url=None, openapi_url=None, ) @app.middleware("http") async def security(request: Request, call_next): if request.method not in {"GET", "HEAD", "OPTIONS"} and request.cookies.get(COOKIE_NAME): csrf_cookie = request.cookies.get(CSRF_COOKIE_NAME) csrf_header = request.headers.get("x-csrf-token") # SameSite cookies plus same-origin validation; API clients may explicitly double-submit. origin = request.headers.get("origin") if origin and (not csrf_cookie or csrf_header != csrf_cookie): return JSONResponse({"detail": "CSRF 校验失败"}, status_code=403) response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "DENY" response.headers["Referrer-Policy"] = "same-origin" response.headers["Content-Security-Policy"] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'" return response @app.get("/api/docs", include_in_schema=False) async def docs(_: User = Depends(current_user)): return get_swagger_ui_html(openapi_url="/api/openapi.json", title="dodo API") @app.get("/api/openapi.json", include_in_schema=False) async def openapi(_: User = Depends(current_user)): return app.openapi() app.include_router(mvp_router) _login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque) @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, request: Request, 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, request) return user @app.post("/api/v1/auth/login", response_model=UserOut) async def login( payload: LoginRequest, response: Response, request: Request, db: AsyncSession = Depends(get_db), ): from .config import get_settings ip = request.client.host if request.client else "unknown" key = (payload.username.casefold(), ip) now = time.monotonic() attempts = _login_attempts[key] window = get_settings().login_window_seconds while attempts and attempts[0] < now - window: attempts.popleft() if len(attempts) >= get_settings().login_attempts: raise HTTPException(status_code=429, detail="登录尝试过多,请稍后再试", headers={"Retry-After": str(window)}) user = await db.scalar(select(User).where(User.username == payload.username)) if user is None or not verify_password(user.password_hash, payload.password): attempts.append(now) raise HTTPException(status_code=401, detail="用户名或密码错误") attempts.clear() await issue_session(db, response, user, request) 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/change-password", status_code=204) async def change_password( payload: ChangePasswordRequest, token: str = Depends(session_token), user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): if not verify_password(user.password_hash, payload.current_password): raise HTTPException(status_code=400, detail="当前密码不正确") user.password_hash = hash_password(payload.new_password) current_hash = hash_token(token) await db.execute( delete(Session).where(Session.user_id == user.id, Session.token_hash != current_hash) ) await db.commit() return Response(status_code=204) @app.get("/api/v1/bootstrap") async def bootstrap_data(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): folders = list((await db.scalars( select(Folder) .where(Folder.user_id == user.id, Folder.deleted_at.is_(None)) .order_by(Folder.position, Folder.created_at) )).all()) lists = list((await db.scalars( select(TaskList) .where(TaskList.user_id == user.id, TaskList.deleted_at.is_(None)) .order_by(TaskList.is_inbox.desc(), TaskList.position, TaskList.created_at) )).all()) inbox = next((item for item in lists if item.is_inbox), None) return { "user": UserOut.model_validate(user), "folders": [FolderOut.model_validate(item) for item in folders], "lists": [ListOut.model_validate(item) for item in lists], "inbox_id": inbox.id if inbox else None, } @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="/") response.delete_cookie(CSRF_COOKIE_NAME, path="/") return Response(status_code=204, headers=response.headers) @app.get("/api/v1/sessions", response_model=list[SessionOut]) async def list_sessions( token: str = Depends(session_token), user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): current_hash = hash_token(token) rows = (await db.scalars(select(Session).where(Session.user_id == user.id).order_by(Session.created_at.desc()))).all() return [{"id": row.id, "current": row.token_hash == current_hash, "created_at": row.created_at, "last_seen_at": row.last_seen_at, "expires_at": row.expires_at, "ip_address": row.ip_address, "user_agent": row.user_agent} for row in rows] @app.delete("/api/v1/sessions/{session_id}", status_code=204) async def revoke_session( session_id: UUID, response: Response, token: str = Depends(session_token), user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): row = await db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id)) if not row: raise HTTPException(404, "会话不存在") current = row.token_hash == hash_token(token) await db.delete(row); await db.commit() if current: response.delete_cookie(COOKIE_NAME, path="/"); response.delete_cookie(CSRF_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.flush() audit(db, user.id, "update", "folder", folder.id) 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() audit(db, user.id, "delete", "folder", folder.id) 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.flush() audit(db, user.id, "create", "list", item.id) await db.commit() await db.refresh(item) return item @app.get("/api/v1/lists", response_model=list[ListOut]) async def list_lists( archived: bool = False, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): deleted_condition = TaskList.deleted_at.is_not(None) if archived else TaskList.deleted_at.is_(None) query = select(TaskList).where(TaskList.user_id == user.id, deleted_condition) 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, *, include_archived: bool = False, ) -> TaskList: conditions = [TaskList.id == list_id, TaskList.user_id == user_id] if not include_archived: conditions.append(TaskList.deleted_at.is_(None)) item = await db.scalar(select(TaskList).where(*conditions)) 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.flush() audit(db, user.id, "update", "list", item.id) await db.commit() await db.refresh(item) 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/lists/{list_id}/restore", response_model=ListOut) async def restore_list( list_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): item = await _owned_list(db, user.id, list_id, include_archived=True) if item.is_inbox or item.deleted_at is None: raise HTTPException(status_code=409, detail="清单未归档") item.deleted_at = None await db.flush() audit(db, user.id, "restore", "list", item.id) await db.commit() await db.refresh(item) return item def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str: raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode() return base64.urlsafe_b64encode(raw).decode().rstrip("=") def _decode_cursor(cursor: str) -> tuple[int, datetime, UUID]: try: raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)) position, timestamp, task_id = json.loads(raw) return int(position), 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) 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() parent_filter = Task.parent_id == payload.parent_id if payload.parent_id else Task.parent_id.is_(None) max_position = await db.scalar(select(func.max(Task.position)).where( Task.user_id == user.id, Task.list_id == payload.list_id, parent_filter, Task.deleted_at.is_(None), )) task = Task(user_id=user.id, position=(max_position if max_position is not None else -1) + 1, **data) db.add(task) await db.flush() audit(db, user.id, "create", "task", task.id) 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, list_id: UUID | None = None, completed: bool | None = None, due_from: datetime | None = None, due_to: datetime | None = None, cursor: str | None = None, page: int | None = Query(default=None, ge=1), page_size: int | None = Query(default=None, ge=1, le=100), 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 list_id is not None: await _owned_list(db, user.id, list_id) query = query.where(Task.list_id == list_id) if completed is not None: query = query.where(Task.completed.is_(completed)) if due_from is not None: query = query.where(Task.due_at >= due_from) if due_to is not None: query = query.where(Task.due_at < due_to) if q: pattern = f"%{q}%" 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), list_match) ) total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 ordering = (Task.position, Task.created_at, Task.id) if page is not None: size = page_size or limit items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size) if cursor: position, created_at, task_id = _decode_cursor(cursor) query = query.where( or_( Task.position > position, (Task.position == position) & (Task.created_at > created_at), (Task.position == position) & (Task.created_at == created_at) & (Task.id > task_id), ) ) rows = list((await db.scalars(query.order_by(*ordering).limit(limit + 1))).all()) has_more = len(rows) > limit items = rows[:limit] next_cursor = _encode_cursor(items[-1].position, items[-1].created_at, items[-1].id) if has_more else None return TaskPage(items=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit) async def _task_details(db: AsyncSession, tasks: list[Task]) -> list[TaskDetailOut]: if not tasks: return [] task_ids = [task.id for task in tasks] subtasks = list( ( await db.scalars( select(Task) .where(Task.parent_id.in_(task_ids), Task.deleted_at.is_(None)) .order_by(Task.position, Task.created_at, Task.id) ) ).all() ) subtasks_by_task: dict[UUID, list[Task]] = defaultdict(list) for subtask in subtasks: subtasks_by_task[subtask.parent_id].append(subtask) return [ TaskDetailOut( **TaskOut.model_validate(task).model_dump(), subtasks=subtasks_by_task[task.id], ) for task in tasks ] async def _task_detail(db: AsyncSession, task: Task) -> TaskDetailOut: return (await _task_details(db, [task]))[0] @app.put("/api/v1/tasks/reorder", status_code=204) async def reorder_tasks( payload: TaskReorder, user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): rows = list((await db.scalars(select(Task).where( Task.id.in_(payload.task_ids), Task.user_id == user.id, Task.deleted_at.is_(None) ))).all()) if len(rows) != len(payload.task_ids): raise HTTPException(status_code=404, detail="任务不存在") parent_scopes = {row.parent_id for row in rows} if len(parent_scopes) != 1: raise HTTPException(status_code=400, detail="只能调整同一层级任务的顺序") parent_id = next(iter(parent_scopes)) scope_query = select(Task).where( Task.user_id == user.id, Task.deleted_at.is_(None), Task.parent_id.is_(None) if parent_id is None else Task.parent_id == parent_id, ).order_by(Task.position, Task.created_at, Task.id) scope_rows = list((await db.scalars(scope_query)).all()) requested = set(payload.task_ids) ordered_rows = iter([next(row for row in rows if row.id == task_id) for task_id in payload.task_ids]) merged = [next(ordered_rows) if row.id in requested else row for row in scope_rows] for position, row in enumerate(merged): row.position = position await db.commit() return Response(status_code=204) @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), ): 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="任务不存在") data = payload.model_dump(exclude_unset=True) expected_version = data.pop("version") recurrence = None if data.get("completed") is True: recurrence = await db.scalar(select(RecurrenceTemplate).where( RecurrenceTemplate.task_id == task_id, RecurrenceTemplate.user_id == user.id )) if recurrence: next_items = occurrences( recurrence.rrule, recurrence.starts_at, recurrence.starts_at + timedelta(microseconds=1), recurrence.starts_at + timedelta(days=3660), recurrence.ends_at, ) if next_items: data["completed"] = False data["due_at"] = next_items[0] recurrence.starts_at = next_items[0] if "list_id" in data: await _owned_list(db, user.id, data["list_id"]) if task.parent_id: parent_list = await db.scalar( select(Task.list_id).where(Task.id == task.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 "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()) ) changed = {k for k in data if k not in {"version", "updated_at"}} if changed & {"title", "description", "priority", "due_at", "list_id", "completed"}: action = "complete" if data.get("completed") is True else "update" audit(db, user.id, action, "task", task.id, fields=sorted(changed)) 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) ) audit(db, user.id, "delete", "task", task.id) await db.commit() return Response(status_code=204) @app.get("/api/v1/trash", response_model=TaskPage) async def list_trash( cursor: str | None = None, page: int | None = Query(default=None, ge=1), page_size: int | None = Query(default=None, ge=1, le=100), 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) ) total = await db.scalar(select(func.count()).select_from(query.order_by(None).subquery())) or 0 ordering = (Task.created_at, Task.id) if page is not None: size = page_size or limit items = list((await db.scalars(query.order_by(*ordering).offset((page - 1) * size).limit(size))).all()) return TaskPage(items=await _task_details(db, items), total=total, page=page, page_size=size) 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(*ordering).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=await _task_details(db, items), next_cursor=next_cursor, total=total, page=1, page_size=limit) @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) ) audit(db, user.id, "restore", "task", task.id) 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="回收站中不存在该任务") 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) standalone_children = [task for task in tasks if task.parent_id is not None] if standalone_children: raise HTTPException(status_code=400, detail="子任务不能脱离父任务单独移动") changes = payload.model_dump(exclude_unset=True, exclude={"task_ids", "soft_delete"}) if payload.soft_delete: changes["deleted_at"] = utcnow() if changes: changes["version"] = Task.version + 1 changes["updated_at"] = utcnow() target_ids = set(task_ids) if payload.soft_delete: parent_ids = [task.id for task in tasks if task.parent_id is None] if parent_ids: child_ids = list( ( await db.scalars( select(Task.id).where( Task.parent_id.in_(parent_ids), Task.user_id == user.id, Task.deleted_at.is_(None), ) ) ).all() ) target_ids.update(child_ids) await db.execute( update(Task) .where(Task.id.in_(target_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(), ) ) 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): if path == "api" or path.startswith("api/"): raise HTTPException(status_code=404, detail="Not Found") root = static_dir.resolve() target = (root / path).resolve() headers = {"Cache-Control": "no-cache, no-store, must-revalidate, max-age=0"} if target.is_file() and target.is_relative_to(root): if target.name == "sw.js": return HTMLResponse( "self.addEventListener('install',()=>self.skipWaiting());" "self.addEventListener('activate',e=>e.waitUntil(" "caches.keys().then(k=>Promise.all(k.map(x=>caches.delete(x))))" ".then(()=>self.registration.unregister())" ".then(()=>self.clients.matchAll({type:'window'}))" ".then(cs=>cs.forEach(c=>c.navigate(c.url)))));", media_type="application/javascript", headers=headers, ) return FileResponse(target) return FileResponse(root / "index.html", headers=headers)