Files
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

527 lines
17 KiB
Python

"""
内容管理 API 路由
复用新闻资讯模块的核心逻辑,关联到新的栏目(columns)体系
"""
import html
import os
import re
import shutil
import subprocess
from datetime import datetime
from typing import Optional, List
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
from app.models.article import Article
from app.models.column import ColumnModel
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(tags=["内容管理"])
# ========== 辅助函数 ==========
def extract_summary(content: str, max_length: int = 100) -> str:
"""从 HTML/Markdown 内容中提取纯文本摘要"""
if not content:
return ""
text = re.sub(r'<[^>]+>', '', content)
text = html.unescape(text)
text = re.sub(r'[#*_`~\[\]\(\)!>-]', '', text)
text = re.sub(r'\s+', ' ', text).strip()
if len(text) > max_length:
return text[:max_length] + "..."
return text
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"article-{int(__import__('time').time())}"
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':
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 表"""
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)
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
# 按年月分目录存储(与 batch_crawl / wechat_crawl 一致)
year_month = datetime.now().strftime("%Y%m")
dest_dir = os.path.join(upload_dir, year_month)
os.makedirs(dest_dir, exist_ok=True)
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(dest_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/{year_month}/{filename}"
formal_thumb = f"/storage/admin/images/thumbnails/{thumb_filename}"
path_mapping[temp_path] = formal_path
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="articles"
)
db.add(img_record)
new_content = content
for temp_path, formal_path in path_mapping.items():
new_content = new_content.replace(temp_path, formal_path)
new_cover = cover_image
if cover_image and cover_image in path_mapping:
new_cover = path_mapping[cover_image]
return new_content, new_cover
def _validate_column_for_article(column_id: int, db: Session, exclude_article_id: Optional[int] = None):
"""校验栏目是否可关联内容:
- 栏目必须存在
- 顶级栏目若有二级栏目,则禁止直接关联内容
"""
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
# 如果是顶级栏目,检查是否有子栏目
if column.parent_id is None:
child_count = db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).count()
if child_count > 0:
raise HTTPException(status_code=400, detail="该栏目存在子栏目,内容必须关联到子栏目")
return column
# ========== Pydantic 模型 ==========
class ArticleCreate(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)
column_id: int
author: Optional[str] = Field(None, max_length=50)
status: str = Field(default="draft", pattern="^(published|draft)$")
sort_order: int = Field(default=0)
class ArticleUpdate(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)
column_id: Optional[int] = None
author: Optional[str] = Field(None, max_length=50)
status: Optional[str] = Field(None, pattern="^(published|draft)$")
sort_order: Optional[int] = None
class ArticlePublish(BaseModel):
is_published: bool
class BatchDelete(BaseModel):
ids: List[int]
class BatchMove(BaseModel):
ids: List[int]
column_id: int
# ========== API ==========
@router.get("/admin/articles")
def list_articles(
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=100),
column_id: Optional[int] = Query(default=None),
keyword: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None, pattern="^(published|draft|rewriting|all)$"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取内容列表(分页 + 筛选)"""
query = db.query(Article).options(joinedload(Article.column))
if column_id:
query = query.filter(Article.column_id == column_id)
if keyword:
query = query.filter(Article.title.contains(keyword))
if status == "published":
query = query.filter(Article.status == "published")
elif status == "draft":
query = query.filter(Article.status == "draft")
query = query.order_by(Article.updated_at.desc())
total = query.count()
articles = query.offset((page - 1) * size).limit(size).all()
return {
"items": [a.to_dict(include_content=False) for a in articles],
"total": total,
"page": page,
"size": size
}
@router.get("/admin/articles/{article_id}")
def get_article(
article_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取内容详情"""
article = db.query(Article).options(joinedload(Article.column)).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
return article.to_dict(include_content=True)
@router.post("/admin/articles")
def create_article(
data: ArticleCreate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""创建内容"""
# 校验栏目
_validate_column_for_article(data.column_id, db)
# 自动生成 slug
slug = data.slug or generate_slug(data.title)
if not slug:
raise HTTPException(status_code=400, detail="URL标识生成失败,请手动填写")
existing = db.query(Article).filter(Article.slug == slug).first()
if existing:
raise HTTPException(status_code=400, 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)
article = Article(
title=data.title,
slug=slug,
content=migrated_content,
content_type=data.content_type,
summary=summary,
cover_image=migrated_cover,
column_id=data.column_id,
author=data.author,
status=data.status,
sort_order=data.sort_order,
)
db.add(article)
db.commit()
db.refresh(article)
return article.to_dict(include_content=True)
@router.put("/admin/articles/{article_id}")
def update_article(
article_id: int,
data: ArticleUpdate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""更新内容"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
# 检查 slug 冲突
slug = data.slug
if slug is not None:
if not slug.strip():
slug = generate_slug(data.title or article.title)
if slug != article.slug:
existing = db.query(Article).filter(Article.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="内容标识已存在")
# 校验栏目
if data.column_id is not None:
_validate_column_for_article(data.column_id, db, exclude_article_id=article_id)
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.get("summary", "").strip():
content_to_extract = update_data.get("content") or article.content
if content_to_extract:
update_data["summary"] = extract_summary(content_to_extract, max_length=100)
# 迁移临时图片(如果内容/封面包含临时路径)
if update_data.get("content"):
migrated_content, migrated_cover = migrate_temp_images(
update_data["content"],
update_data.get("cover_image") or article.cover_image,
db,
)
update_data["content"] = migrated_content
update_data["cover_image"] = migrated_cover
for key, value in update_data.items():
setattr(article, key, value)
db.commit()
db.refresh(article)
return article.to_dict(include_content=True)
@router.delete("/admin/articles/{article_id}")
def delete_article(
article_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""删除内容"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
if article.cover_image:
cleanup_file_by_path(article.cover_image)
db.delete(article)
db.commit()
return {"message": "删除成功"}
@router.post("/admin/articles/batch-delete")
def batch_delete_articles(
data: BatchDelete,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""批量删除内容"""
articles = db.query(Article).filter(Article.id.in_(data.ids)).all()
for article in articles:
if article.cover_image:
cleanup_file_by_path(article.cover_image)
db.delete(article)
db.commit()
return {"message": f"成功删除 {len(articles)} 条内容"}
@router.post("/admin/articles/batch-move")
def batch_move_articles(
data: BatchMove,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""批量转移栏目"""
# 校验目标栏目
_validate_column_for_article(data.column_id, db)
articles = db.query(Article).filter(Article.id.in_(data.ids)).all()
for article in articles:
article.column_id = data.column_id
db.commit()
return {"message": f"成功转移 {len(articles)} 条内容"}
@router.post("/admin/articles/{article_id}/publish")
def publish_article(
article_id: int,
data: ArticlePublish,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""发布/取消发布内容"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
article.status = "published" if data.is_published else "draft"
db.commit()
db.refresh(article)
return article.to_dict()
# ========== 改写发布 ==========
VENV_PYTHON = "/mnt/datadisk0/www/new-media-platform/website-new/backend/.venv/bin/python3"
PIPELINE_PATH = "/mnt/datadisk0/www/new-media-platform/website-new/rewrite/pipeline.py"
LOG_FILE = "/mnt/datadisk0/www/new-media-platform/website-new/rewrite/logs/pipeline.log"
@router.post("/admin/articles/{article_id}/rewrite-publish")
def rewrite_publish_article(
article_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""一键改写+发布到公众号(异步后台任务)"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
if article.status != "draft":
raise HTTPException(status_code=400, detail="只有草稿状态的内容可以改写发布")
# 并发控制:检查是否有正在改写中的文章
rewriting_count = db.query(Article).filter(
Article.status == "rewriting"
).count()
if rewriting_count > 0:
raise HTTPException(status_code=429, detail="已有文章正在改写中,请稍后再试")
# 设置为改写中
article.status = "rewriting"
db.commit()
db.refresh(article)
# 启动后台子进程,stderr 写入日志文件便于排查
stderr_log = open(LOG_FILE, "a")
subprocess.Popen(
[VENV_PYTHON, PIPELINE_PATH, str(article_id)],
stdout=stderr_log,
stderr=stderr_log,
)
return {
"message": "改写任务已启动",
"article_id": article_id,
"status": "rewriting",
}
# ========== 访客端 Public API ==========
@router.get("/public/articles")
def list_public_articles(
page: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=100),
column_slug: Optional[str] = Query(default=None),
db: Session = Depends(get_db),
):
"""访客端:获取已发布的内容列表"""
query = db.query(Article).options(joinedload(Article.column)).filter(
Article.status == "published"
)
# 如果指定了栏目 slug,筛选该栏目及其子栏目的内容
if column_slug:
column = db.query(ColumnModel).filter(
ColumnModel.slug == column_slug,
ColumnModel.is_enabled == True
).first()
if column:
# 获取该栏目及所有子栏目的 ID
column_ids = [column.id]
children = db.query(ColumnModel).filter(
ColumnModel.parent_id == column.id
).all()
column_ids.extend([c.id for c in children])
query = query.filter(Article.column_id.in_(column_ids))
query = query.order_by(Article.sort_order.asc(), Article.updated_at.desc())
total = query.count()
articles = query.offset((page - 1) * size).limit(size).all()
return {
"items": [a.to_dict(include_content=False) for a in articles],
"total": total,
"page": page,
"size": size
}
@router.get("/public/articles/{slug}")
def get_public_article(
slug: str,
db: Session = Depends(get_db),
):
"""访客端:通过 slug 获取内容详情"""
article = db.query(Article).options(joinedload(Article.column)).filter(
Article.slug == slug,
Article.status == "published"
).first()
if not article:
raise HTTPException(status_code=404, detail="内容不存在")
# 增加浏览次数
article.view_count += 1
db.commit()
return article.to_dict(include_content=True)