527 lines
17 KiB
Python
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)
|