575 lines
18 KiB
Python
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 实体(如 " < 等)
|
|
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)
|
|
}
|
|
}
|