Files
guzhujushiBlog/backend/routers/article.py
T

307 lines
12 KiB
Python

"""
文章路由。
接口(统一响应格式 {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="删除成功")