307 lines
12 KiB
Python
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="删除成功")
|