Files
nanocode/new-media-platform/website-new/backend/app/routers/news.py
T
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

575 lines
18 KiB
Python

# ⚠️ 此新闻模块已废弃,不再使用。
# 已被「栏目管理 + 内容管理」(columns.py + articles.py)取代。
# 请勿删除此注释,供后续维护人员参考。
"""
新闻资讯 API 路由(已废弃)
包含栏目管理和资讯管理
"""
import html
import os
import re
import shutil
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from pydantic import BaseModel, Field
from pypinyin import lazy_pinyin
def extract_summary(content: str, max_length: int = 100) -> str:
"""从 HTML/Markdown 内容中提取纯文本摘要"""
if not content:
return ""
# 去除 HTML 标签
text = re.sub(r'<[^>]+>', '', content)
# 解码 HTML 实体(如 &quot; &nbsp; &lt; 等)
text = html.unescape(text)
# 去除 Markdown 标记(简化处理)
text = re.sub(r'[#*_`~\[\]\(\)!>-]', '', text)
# 去除多余空白
text = re.sub(r'\s+', ' ', text).strip()
# 截取摘要
if len(text) > max_length:
return text[:max_length] + "..."
return text
from app.models.news_category import NewsCategory
from app.models.news_post import NewsPost
from app.models.image import Image
from app.routers.auth import get_current_admin
from app.utils.file_utils import cleanup_file_by_path
from app.database import get_db
router = APIRouter(prefix="/news", tags=["news"])
def generate_slug(text: str) -> str:
"""将中文标题转为拼音 slug"""
if not text:
return ''
# 转拼音,无音调
py = lazy_pinyin(text, style=0)
slug = '-'.join(py)
# 清理特殊字符
slug = re.sub(r'[^a-zA-Z0-9-]', '', slug).lower()
# 去重横线
slug = re.sub(r'-+', '-', slug).strip('-')
return slug or f'post-{int(__import__("time").time())}'
# ========== Pydantic 模型 ==========
class CategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=50)
slug: Optional[str] = Field(None, max_length=50)
description: Optional[str] = Field(None, max_length=200)
sort_order: int = Field(default=0)
is_enabled: bool = Field(default=True)
class CategoryUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=50)
slug: Optional[str] = Field(None, min_length=1, max_length=50)
description: Optional[str] = Field(None, max_length=200)
sort_order: Optional[int] = None
is_enabled: Optional[bool] = None
class PostCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=200)
slug: Optional[str] = Field(None, max_length=200)
content: str = Field(..., min_length=1)
content_type: str = Field(default="markdown", pattern="^(markdown|html)$")
summary: Optional[str] = Field(None, max_length=500)
cover_image: Optional[str] = Field(None, max_length=500)
category_id: int
is_draft: bool = Field(default=True)
class PostUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=200)
slug: Optional[str] = Field(None, min_length=1, max_length=200)
content: Optional[str] = Field(None, min_length=1)
content_type: Optional[str] = Field(None, pattern="^(markdown|html)$")
summary: Optional[str] = Field(None, max_length=500)
cover_image: Optional[str] = Field(None, max_length=500)
category_id: Optional[int] = None
is_draft: Optional[bool] = None
class PostPublish(BaseModel):
is_published: bool
# ========== 临时图片迁移辅助函数 ==========
TEMP_STORAGE_PREFIX = "/storage/tmp/crawl"
def get_upload_dir():
"""获取上传目录路径"""
from app.config import get_settings
settings = get_settings()
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
return os.path.join(base_dir, settings.UPLOAD_DIR)
def get_crawl_temp_dir():
"""获取采集临时目录 - 跨平台"""
if os.name == 'nt': # Windows
base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
return os.path.join(base_dir, 'storage', 'tmp', 'crawl')
return '/tmp/news_crawl'
def migrate_temp_images(content: str, cover_image: Optional[str], db: Session):
"""
将内容中的临时图片迁移到正式目录,并写入 images 表。
返回更新后的 (content, cover_image)。
"""
upload_dir = get_upload_dir()
temp_dir = get_crawl_temp_dir()
# 收集所有需要迁移的临时路径
temp_paths = set()
if cover_image and cover_image.startswith(TEMP_STORAGE_PREFIX):
temp_paths.add(cover_image)
# 从 content 中查找所有临时图片路径
pattern = re.escape(TEMP_STORAGE_PREFIX) + r'/[^\\)"\'>\s]+'
for match in re.finditer(pattern, content):
temp_paths.add(match.group())
if not temp_paths:
return content, cover_image
# 路径映射: 临时路径 -> 正式路径
path_mapping = {}
for temp_path in temp_paths:
filename = os.path.basename(temp_path)
temp_file = os.path.join(temp_dir, filename)
if not os.path.exists(temp_file):
continue
# 移动到正式目录
dest_file = os.path.join(upload_dir, filename)
shutil.move(temp_file, dest_file)
# 迁移缩略图
thumb_filename = f"{os.path.splitext(filename)[0]}.jpg"
temp_thumb = os.path.join(temp_dir, "thumbnails", thumb_filename)
dest_thumb = os.path.join(upload_dir, "thumbnails", thumb_filename)
if os.path.exists(temp_thumb):
shutil.move(temp_thumb, dest_thumb)
formal_path = f"/storage/admin/images/{filename}"
formal_thumb = f"/storage/admin/images/thumbnails/{thumb_filename}"
path_mapping[temp_path] = formal_path
# 写入 images 表
try:
file_size = os.path.getsize(dest_file)
except OSError:
file_size = 0
existing = db.query(Image).filter(Image.file_path == formal_path).first()
if not existing:
img_record = Image(
filename=filename,
original_name=filename,
file_path=formal_path,
thumb_path=formal_thumb,
file_size=file_size,
module="news"
)
db.add(img_record)
# 替换 content 中的路径
new_content = content
for temp_path, formal_path in path_mapping.items():
new_content = new_content.replace(temp_path, formal_path)
# 替换 cover_image
new_cover = cover_image
if cover_image and cover_image in path_mapping:
new_cover = path_mapping[cover_image]
return new_content, new_cover
# ========== 栏目管理 API ==========
@router.get("/categories")
def list_categories(
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
include_count: bool = Query(default=True, description="是否包含文章数量")
):
"""获取栏目列表"""
query = db.query(NewsCategory).order_by(NewsCategory.sort_order.asc())
categories = query.all()
# 获取每个栏目的文章数量
if include_count:
counts = db.query(
NewsPost.category_id,
func.count(NewsPost.id).label("count")
).group_by(NewsPost.category_id).all()
count_map = {c.category_id: c.count for c in counts}
for cat in categories:
cat.post_count = count_map.get(cat.id, 0)
return {
"items": [cat.to_dict(include_post_count=include_count) for cat in categories],
"total": len(categories)
}
@router.post("/categories")
def create_category(
data: CategoryCreate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""创建栏目"""
# 自动生成 slug(如果未提供)
slug = data.slug or generate_slug(data.name)
# 检查 slug 是否已存在
existing = db.query(NewsCategory).filter(NewsCategory.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="栏目标识已存在")
category = NewsCategory(**{**data.model_dump(), "slug": slug})
db.add(category)
db.commit()
db.refresh(category)
return category.to_dict()
@router.get("/categories/{category_id}")
def get_category(
category_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""获取栏目详情"""
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
return category.to_dict(include_post_count=True)
@router.put("/categories/{category_id}")
def update_category(
category_id: int,
data: CategoryUpdate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""更新栏目"""
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
# 检查 slug 是否冲突
if data.slug and data.slug != category.slug:
existing = db.query(NewsCategory).filter(NewsCategory.slug == data.slug).first()
if existing:
raise HTTPException(status_code=400, detail="栏目标识已存在")
for key, value in data.model_dump(exclude_unset=True).items():
setattr(category, key, value)
db.commit()
db.refresh(category)
return category.to_dict()
@router.delete("/categories/{category_id}")
def delete_category(
category_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""删除栏目"""
category = db.query(NewsCategory).filter(NewsCategory.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
# 检查是否有文章使用此栏目
post_count = db.query(NewsPost).filter(NewsPost.category_id == category_id).count()
if post_count > 0:
raise HTTPException(status_code=400, detail="该栏目下还有文章,无法删除")
db.delete(category)
db.commit()
return {"message": "删除成功"}
# ========== 资讯管理 API ==========
@router.get("/posts")
def list_posts(
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=100),
category_id: Optional[int] = Query(default=None),
keyword: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None, pattern="^(published|draft|all)$"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""获取资讯列表"""
query = db.query(NewsPost).options(joinedload(NewsPost.category))
# 筛选条件
if category_id:
query = query.filter(NewsPost.category_id == category_id)
if keyword:
query = query.filter(NewsPost.title.contains(keyword))
if status == "published":
query = query.filter(NewsPost.is_published == True)
elif status == "draft":
query = query.filter(NewsPost.is_draft == True)
# 排序:先按创建时间倒序
query = query.order_by(NewsPost.created_at.desc())
# 分页
total = query.count()
posts = query.offset((page - 1) * size).limit(size).all()
return {
"items": [post.to_dict(include_content=False) for post in posts],
"total": total,
"page": page,
"size": size
}
@router.post("/posts")
def create_post(
data: PostCreate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""创建资讯"""
# 自动生成 slug(如果未提供)
slug = data.slug or generate_slug(data.title)
# 检查 slug 是否已存在
existing = db.query(NewsPost).filter(NewsPost.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="文章标识已存在")
# 检查栏目是否存在
category = db.query(NewsCategory).filter(NewsCategory.id == data.category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
# 自动生成摘要(如果未提供)
summary = data.summary
if not summary:
summary = extract_summary(data.content, max_length=100)
# 迁移临时图片到正式目录
migrated_content, migrated_cover = migrate_temp_images(data.content, data.cover_image, db)
post = NewsPost(
title=data.title,
slug=slug,
content=migrated_content,
content_type=data.content_type,
summary=summary,
cover_image=migrated_cover,
category_id=data.category_id,
is_draft=data.is_draft,
is_published=not data.is_draft
)
db.add(post)
db.commit()
db.refresh(post)
return post.to_dict(include_content=True)
@router.get("/posts/{post_id}")
def get_post(
post_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""获取资讯详情"""
post = db.query(NewsPost).options(joinedload(NewsPost.category)).filter(NewsPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
return post.to_dict(include_content=True)
@router.put("/posts/{post_id}")
def update_post(
post_id: int,
data: PostUpdate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""更新资讯"""
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
# 检查 slug 是否冲突(如果提供了新的 slug)
slug = data.slug
if slug is not None:
if not slug.strip():
slug = generate_slug(data.title or post.title)
if slug != post.slug:
existing = db.query(NewsPost).filter(NewsPost.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="文章标识已存在")
# 检查栏目是否存在
if data.category_id:
category = db.query(NewsCategory).filter(NewsCategory.id == data.category_id).first()
if not category:
raise HTTPException(status_code=404, detail="栏目不存在")
update_data = data.model_dump(exclude_unset=True)
if slug is not None:
update_data['slug'] = slug
# 如果摘要被清空,自动从内容重新提取
if "summary" in update_data and not update_data["summary"].strip():
content_to_extract = update_data.get("content") or post.content
if content_to_extract:
update_data["summary"] = extract_summary(content_to_extract, max_length=100)
# 处理草稿/发布状态联动
if "is_draft" in update_data:
update_data["is_published"] = not update_data["is_draft"]
for key, value in update_data.items():
setattr(post, key, value)
db.commit()
db.refresh(post)
return post.to_dict(include_content=True)
@router.delete("/posts/{post_id}")
def delete_post(
post_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""删除资讯"""
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
# 删除封面图及其缩略图
if post.cover_image:
cleanup_file_by_path(post.cover_image)
db.delete(post)
db.commit()
return {"message": "删除成功"}
@router.post("/posts/{post_id}/publish")
def publish_post(
post_id: int,
data: PostPublish,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin)
):
"""发布/取消发布资讯"""
post = db.query(NewsPost).filter(NewsPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
post.is_published = data.is_published
post.is_draft = not data.is_published
db.commit()
db.refresh(post)
return post.to_dict()
# ========== 访客端 API(无需认证)==========
@router.get("/public/posts")
def list_public_posts(
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=50),
category_slug: Optional[str] = Query(default=None),
db: Session = Depends(get_db)
):
"""访客端:获取已发布的资讯列表(仅显示 is_enabled=True 栏目下的文章)"""
query = db.query(NewsPost).options(joinedload(NewsPost.category)).join(
NewsCategory, NewsPost.category_id == NewsCategory.id
).filter(
NewsPost.is_published == True,
NewsCategory.is_enabled == True
)
# 按栏目筛选
if category_slug:
category = db.query(NewsCategory).filter(NewsCategory.slug == category_slug).first()
if category:
query = query.filter(NewsPost.category_id == category.id)
query = query.order_by(NewsPost.created_at.desc())
total = query.count()
posts = query.offset((page - 1) * size).limit(size).all()
return {
"items": [post.to_dict(include_content=False) for post in posts],
"total": total,
"page": page,
"size": size
}
@router.get("/public/posts/{slug}")
def get_public_post(
slug: str,
db: Session = Depends(get_db)
):
"""访客端:通过 slug 获取文章详情"""
post = db.query(NewsPost).options(joinedload(NewsPost.category)).filter(
NewsPost.slug == slug,
NewsPost.is_published == True
).first()
if not post:
raise HTTPException(status_code=404, detail="文章不存在")
# 增加浏览次数
post.view_count += 1
db.commit()
return post.to_dict(include_content=True)
@router.get("/public/categories")
def list_public_categories(
db: Session = Depends(get_db)
):
"""访客端:获取启用的栏目列表"""
categories = db.query(NewsCategory).filter(
NewsCategory.is_enabled == True
).order_by(NewsCategory.sort_order.asc()).all()
return {
"data": {
"items": [cat.to_dict() for cat in categories],
"total": len(categories)
}
}