""" 内容管理 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)