""" 文章路由。 接口(统一响应格式 {success, data, message}): - POST /api/article/add 发布文章(仅 blogger),支持封面与 public / friend 可见性 - GET /api/article/list 文章列表(分页):所有用户可见;好友文章对游客仅展示标题与封面 - GET /api/article/{id} 文章详情:好友文章对游客仅返回标题与封面,不返回正文 - PUT /api/article/{id} 更新文章(仅 blogger,字段可选) - DELETE /api/article/{id} 删除文章(仅 blogger,级联删除评论与点赞) """ import os import re from pathlib import Path from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import or_ from sqlalchemy.orm import Session from ..auth import get_current_user from ..database import PROJECT_ROOT, get_db from ..models import ( ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY, ROLE_BLOGGER, VISIBILITY_FRIEND, VISIBILITY_PUBLIC, Article, User, ) from ..schemas import ArticleCreate, ArticleUpdate, UnifiedResponse from .deps import can_read_friend_article, get_optional_user router = APIRouter(prefix="/api/article", tags=["article"]) # 上传根目录(与 routers/upload.py 解析方式一致,可通过 .env 的 UPLOADS_DIR 覆盖) UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads")) # 允许清理的上传子目录白名单(严格限定,防止路径穿越) UPLOAD_SUBDIRS = {"avatar", "article", "project"} def _resolve_upload_file(subdir: str, filename: str) -> Optional[Path]: """把上传子目录与文件名解析为安全路径;子目录不在白名单或文件名含路径分隔符时返回 None。""" if subdir not in UPLOAD_SUBDIRS: return None if not filename or "/" in filename or "\\" in filename or ".." in filename: return None target = (UPLOADS_ROOT / subdir / filename).resolve() root = UPLOADS_ROOT.resolve() if root not in target.parents: return None return target def _collect_upload_paths(article: Article) -> list: """收集文章关联的上传文件路径:封面 + 正文 Markdown 图片(仅站内 /uploads/ 路径)。""" paths = [] seen = set() def add_if_safe(subdir: str, filename: str) -> None: if not filename or filename in seen: return path = _resolve_upload_file(subdir, filename) if path is not None: seen.add(filename) paths.append(path) def parse_url(url: str) -> None: # 仅处理站内路径:/uploads/<子目录>/<随机文件名> parts = (url or "").split("/") if len(parts) == 4 and parts[1] == "uploads": add_if_safe(parts[2], parts[3]) parse_url(article.cover or "") # 同时解析正文图片 ![]() 与视频 @[视频]() 两种站内资源引用 for match in re.finditer(r"(?:!\[[^\]]*\]|@\[[^\]]*\]|\[[^\]]*\])\(([^)\s]+)\)", article.content or ""): parse_url(match.group(1).strip()) return paths def _delete_upload_files(paths: list) -> None: """尽力删除文件:文件不存在或删除失败都不影响文章删除结果(不阻断主流程)。""" for path in paths: try: path.unlink(missing_ok=True) except OSError: # 文件被占用 / 权限不足等场景:跳过,避免文章删除失败 pass def _check_visibility(visibility: str) -> None: """校验文章可见性取值。""" if visibility not in (VISIBILITY_PUBLIC, VISIBILITY_FRIEND): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="visibility 只能为 public 或 friend") def _check_category(category: str) -> None: """校验文章分区取值(life 生活 / study 学习)。""" if category not in (ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="category 只能为 life 或 study") def _require_blogger(user: User) -> None: """校验当前用户是否为博主。""" if user.role != ROLE_BLOGGER: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以操作文章") @router.post("/add", response_model=UnifiedResponse) def add_article( payload: ArticleCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> UnifiedResponse: """发布文章:仅博主可操作,支持封面与 public / friend 两种可见性。""" _require_blogger(current_user) _check_visibility(payload.visibility) category = payload.category or ARTICLE_CATEGORY_LIFE _check_category(category) title = payload.title.strip() content = payload.content.strip() cover = (payload.cover or "").strip() or None if not title: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章标题不能为空") if not content: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章内容不能为空") article = Article( title=title, content=content, cover=cover, visibility=payload.visibility, category=category, author_id=current_user.id, ) db.add(article) db.commit() db.refresh(article) data = { "id": article.id, "title": article.title, "cover": article.cover, "visibility": article.visibility, "category": article.category or ARTICLE_CATEGORY_LIFE, "created_time": article.created_time, } return UnifiedResponse(success=True, data=data, message="发布成功") @router.get("/list", response_model=UnifiedResponse) def list_articles( page: int = Query(1, ge=1, description="页码,从 1 开始"), page_size: int = Query(10, ge=1, le=100, description="每页数量"), category: Optional[str] = Query(None, description="分区过滤:life / study(不传返回全部分区)"), current_user: Optional[User] = Depends(get_optional_user), db: Session = Depends(get_db), ) -> UnifiedResponse: """文章列表(分页):公开文章所有人可见,好友文章对游客仅展示标题与封面。""" privileged = can_read_friend_article(current_user) query = db.query(Article).order_by(Article.created_time.desc(), Article.id.desc()) if category: _check_category(category) if category == ARTICLE_CATEGORY_LIFE: # 旧数据 category 为空视为生活区 query = query.filter(or_(Article.category == category, Article.category.is_(None))) else: query = query.filter(Article.category == category) total = query.count() articles = query.offset((page - 1) * page_size).limit(page_size).all() items = [] for a in articles: if a.visibility == VISIBILITY_FRIEND and not privileged: # 游客/匿名用户:好友文章仅展示标题与封面 items.append({ "id": a.id, "title": a.title, "cover": a.cover, "visibility": a.visibility, "category": a.category or ARTICLE_CATEGORY_LIFE, }) continue items.append({ "id": a.id, "title": a.title, "cover": a.cover, "visibility": a.visibility, "category": a.category or ARTICLE_CATEGORY_LIFE, "author_id": a.author_id, "author_username": a.author.username if a.author else None, "created_time": a.created_time, }) data = { "items": items, "total": total, "page": page, "page_size": page_size, } return UnifiedResponse(success=True, data=data, message="获取成功") @router.get("/{article_id}", response_model=UnifiedResponse) def get_article( article_id: int, current_user: Optional[User] = Depends(get_optional_user), db: Session = Depends(get_db), ) -> UnifiedResponse: """文章详情:好友文章对游客仅返回标题与封面,不返回正文。""" article = db.query(Article).filter(Article.id == article_id).first() if article is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在") if article.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user): data = { "id": article.id, "title": article.title, "cover": article.cover, "visibility": article.visibility, "category": article.category or ARTICLE_CATEGORY_LIFE, "content": None, } return UnifiedResponse(success=True, data=data, message="该文章仅好友可见,正文不可查看") data = { "id": article.id, "title": article.title, "cover": article.cover, "content": article.content, "visibility": article.visibility, "category": article.category or ARTICLE_CATEGORY_LIFE, "author_id": article.author_id, "author_username": article.author.username if article.author else None, "created_time": article.created_time, } return UnifiedResponse(success=True, data=data, message="获取成功") @router.put("/{article_id}", response_model=UnifiedResponse) def update_article( article_id: int, payload: ArticleUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> UnifiedResponse: """更新文章:仅博主可操作;仅更新传入的字段,未传字段保持不变。""" _require_blogger(current_user) article = db.query(Article).filter(Article.id == article_id).first() if article is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在") if payload.visibility is not None: _check_visibility(payload.visibility) article.visibility = payload.visibility if payload.category is not None: _check_category(payload.category) article.category = payload.category if payload.title is not None: title = payload.title.strip() if not title: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章标题不能为空") article.title = title if payload.content is not None: content = payload.content.strip() if not content: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章内容不能为空") article.content = content if payload.cover is not None: article.cover = payload.cover.strip() or None db.commit() db.refresh(article) data = { "id": article.id, "title": article.title, "cover": article.cover, "visibility": article.visibility, "category": article.category or ARTICLE_CATEGORY_LIFE, } return UnifiedResponse(success=True, data=data, message="更新成功") @router.delete("/{article_id}", response_model=UnifiedResponse) def delete_article( article_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), ) -> UnifiedResponse: """删除文章:仅博主可操作;关联评论与点赞随文章级联删除。""" _require_blogger(current_user) article = db.query(Article).filter(Article.id == article_id).first() if article is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在") # 删除前收集关联的上传文件(封面与正文图片),防止删除文章后留下孤儿文件 upload_paths = _collect_upload_paths(article) db.delete(article) db.commit() # 数据删除成功后再清理文件(失败不阻断,避免因文件权限问题导致文章无法删除) _delete_upload_files(upload_paths) return UnifiedResponse(success=True, data={"id": article_id}, message="删除成功")