from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlmodel import Session, select, func from ..database import get_session from ..models import Novel, Outline from ..schemas import ( OutlineCreate, OutlineUpdate, OutlineRead, OutlineList, OutlineReorder, ) router = APIRouter(prefix="/api/novels/{novel_id}/outlines", tags=["outlines"]) def _get_novel_or_404(novel_id: int, session: Session) -> Novel: novel = session.get(Novel, novel_id) if not novel: raise HTTPException(status_code=404, detail="小说不存在") return novel def _get_outline_or_404( outline_id: int, novel_id: int, session: Session ) -> Outline: outline = session.get(Outline, outline_id) if not outline or outline.novel_id != novel_id: raise HTTPException(status_code=404, detail="大纲不存在") return outline def _build_outline_read(outline: Outline, children: list[Outline]) -> OutlineRead: """将 Outline ORM 对象转为 OutlineRead,附带子节点列表""" return OutlineRead( id=outline.id, novel_id=outline.novel_id, parent_id=outline.parent_id, sort_order=outline.sort_order, summary=outline.summary, detail=outline.detail, children=[ OutlineRead( id=c.id, novel_id=c.novel_id, parent_id=c.parent_id, sort_order=c.sort_order, summary=c.summary, detail=c.detail, children=[], created_at=c.created_at, updated_at=c.updated_at, ) for c in children ], created_at=outline.created_at, updated_at=outline.updated_at, ) @router.get("", response_model=OutlineList) def list_outlines( novel_id: int, session: Session = Depends(get_session), ): _get_novel_or_404(novel_id, session) # 只查顶级节点(parent_id IS NULL) top_items = session.exec( select(Outline) .where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore .order_by(Outline.sort_order) ).all() # 查询所有子节点,按 parent_id 分组 all_children = session.exec( select(Outline) .where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore .order_by(Outline.sort_order) ).all() children_map: dict[int, list[Outline]] = {} for child in all_children: children_map.setdefault(child.parent_id, []).append(child) items = [ _build_outline_read(o, children_map.get(o.id, [])) for o in top_items ] return OutlineList(items=items, total=len(items)) @router.post("", response_model=OutlineRead, status_code=201) def create_outline( novel_id: int, data: OutlineCreate, session: Session = Depends(get_session), ): _get_novel_or_404(novel_id, session) # 如果指定了 parent_id,校验父节点存在且是顶级节点 if data.parent_id is not None: parent = session.get(Outline, data.parent_id) if not parent or parent.novel_id != novel_id: raise HTTPException(status_code=404, detail="父节点不存在") if parent.parent_id is not None: raise HTTPException(status_code=400, detail="仅支持两级结构,不能在子节点下创建子节点") # 同级内自增 sort_order(同 parent_id 下的 max + 1) if data.parent_id is not None: max_order = session.exec( select(func.max(Outline.sort_order)) .where(Outline.novel_id == novel_id, Outline.parent_id == data.parent_id) ).one() else: max_order = session.exec( select(func.max(Outline.sort_order)) .where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore ).one() next_order = (max_order or 0) + 1 outline = Outline( novel_id=novel_id, parent_id=data.parent_id, sort_order=next_order, summary=data.summary, detail=data.detail, ) session.add(outline) session.commit() session.refresh(outline) # 返回时附带空 children return _build_outline_read(outline, []) @router.put("/reorder", response_model=OutlineList) def reorder_outlines( novel_id: int, data: OutlineReorder, session: Session = Depends(get_session), ): """仅处理顶级节点排序""" _get_novel_or_404(novel_id, session) now = datetime.now(timezone.utc) for idx, oid in enumerate(data.ordered_ids): outline = _get_outline_or_404(oid, novel_id, session) if outline.parent_id is not None: raise HTTPException(status_code=400, detail=f"节点 {oid} 不是顶级节点,不能在此排序") outline.sort_order = idx + 1 outline.updated_at = now session.add(outline) session.commit() # 重新查询并返回嵌套结构 return list_outlines(novel_id, session) @router.put("/{outline_id}/children/reorder", response_model=OutlineRead) def reorder_children( novel_id: int, outline_id: int, data: OutlineReorder, session: Session = Depends(get_session), ): """子节点排序""" _get_novel_or_404(novel_id, session) parent = _get_outline_or_404(outline_id, novel_id, session) if parent.parent_id is not None: raise HTTPException(status_code=400, detail="只有顶级节点可以排序子节点") now = datetime.now(timezone.utc) for idx, cid in enumerate(data.ordered_ids): child = _get_outline_or_404(cid, novel_id, session) if child.parent_id != outline_id: raise HTTPException(status_code=400, detail=f"节点 {cid} 不是该父节点的子节点") child.sort_order = idx + 1 child.updated_at = now session.add(child) session.commit() # 查询更新后的子节点 children = session.exec( select(Outline) .where(Outline.novel_id == novel_id, Outline.parent_id == outline_id) .order_by(Outline.sort_order) ).all() session.refresh(parent) return _build_outline_read(parent, list(children)) @router.get("/{outline_id}", response_model=OutlineRead) def get_outline( novel_id: int, outline_id: int, session: Session = Depends(get_session), ): outline = _get_outline_or_404(outline_id, novel_id, session) # 查询子节点 children = session.exec( select(Outline) .where(Outline.novel_id == novel_id, Outline.parent_id == outline_id) .order_by(Outline.sort_order) ).all() return _build_outline_read(outline, list(children)) @router.patch("/{outline_id}", response_model=OutlineRead) def update_outline( novel_id: int, outline_id: int, data: OutlineUpdate, session: Session = Depends(get_session), ): outline = _get_outline_or_404(outline_id, novel_id, session) update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(outline, key, value) outline.updated_at = datetime.now(timezone.utc) session.add(outline) session.commit() session.refresh(outline) # 查询子节点 children = session.exec( select(Outline) .where(Outline.novel_id == novel_id, Outline.parent_id == outline_id) .order_by(Outline.sort_order) ).all() return _build_outline_read(outline, list(children)) @router.delete("/{outline_id}", status_code=204) def delete_outline( novel_id: int, outline_id: int, session: Session = Depends(get_session), ): outline = _get_outline_or_404(outline_id, novel_id, session) # 删除父节点时先删除所有子节点 children = session.exec( select(Outline) .where(Outline.novel_id == novel_id, Outline.parent_id == outline_id) ).all() for child in children: session.delete(child) session.delete(outline) session.commit()