Initial commit: MyBlog full stack blog
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
文章路由。
|
||||
|
||||
接口(统一响应格式 {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="删除成功")
|
||||
Reference in New Issue
Block a user