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

298 lines
10 KiB
Python

"""
栏目管理 API 路由
"""
import re
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from pydantic import BaseModel, Field
from pypinyin import lazy_pinyin
from app.models.column import ColumnModel
from app.routers.auth import get_current_admin
from app.database import get_db
router = APIRouter(tags=["栏目管理"])
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"column-{int(__import__('time').time())}"
# ========== Pydantic 模型 ==========
class ColumnCreate(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)
parent_id: Optional[int] = Field(None)
sort_order: int = Field(default=0)
is_enabled: bool = Field(default=True)
display_mode: str = Field(default="list", pattern="^(list|card)$")
layout_style: str = Field(default="left", pattern="^(left|right|top)$")
icon_name: Optional[str] = Field(None, max_length=50)
class ColumnUpdate(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)
parent_id: Optional[int] = Field(None)
sort_order: Optional[int] = None
is_enabled: Optional[bool] = None
display_mode: Optional[str] = Field(None, pattern="^(list|card)$")
layout_style: Optional[str] = Field(None, pattern="^(left|right|top)$")
icon_name: Optional[str] = Field(None, max_length=50)
class ColumnTreeOut(BaseModel):
"""树形栏目输出"""
id: int
name: str
slug: str
description: Optional[str]
parent_id: Optional[int]
sort_order: int
is_enabled: bool
display_mode: str
layout_style: str
children: List["ColumnTreeOut"] = []
class Config:
from_attributes = True
# ========== 辅助函数 ==========
def _build_tree(columns: list, parent_id: Optional[int] = None) -> list:
"""递归构建栏目树"""
tree = []
for col in columns:
if col.parent_id == parent_id:
node = col.to_dict()
node["children"] = _build_tree(columns, col.id)
tree.append(node)
return tree
def _validate_parent(column: ColumnModel, new_parent_id: Optional[int], db: Session):
"""校验 parent_id 是否合法:最多两级"""
if new_parent_id is None:
return
# 不能把自己设为自己的父栏目
if column and new_parent_id == column.id:
raise HTTPException(status_code=400, detail="不能将栏目设为自己的上级栏目")
# 父栏目必须存在
parent = db.query(ColumnModel).filter(ColumnModel.id == new_parent_id).first()
if not parent:
raise HTTPException(status_code=404, detail="上级栏目不存在")
# 父栏目本身不能是二级栏目(最多两级)
if parent.parent_id is not None:
raise HTTPException(status_code=400, detail="最多支持两级栏目,不能将二级栏目设为上级")
def _has_children(column_id: int, db: Session) -> bool:
"""检查栏目是否有子栏目"""
return db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).first() is not None
# ========== API ==========
@router.get("/admin/columns")
def list_columns(
tree: bool = Query(default=False, description="是否返回树形结构"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取栏目列表(后台管理)"""
columns = db.query(ColumnModel).order_by(ColumnModel.sort_order.asc()).all()
if tree:
return {"items": _build_tree(columns), "total": len(columns)}
return {"items": [col.to_dict() for col in columns], "total": len(columns)}
@router.get("/admin/columns/tree")
def get_column_tree(
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取树形栏目列表(用于下拉选择,后台管理)"""
columns = db.query(ColumnModel).order_by(ColumnModel.sort_order.asc()).all()
return {"items": _build_tree(columns), "total": len(columns)}
@router.get("/admin/columns/{column_id}")
def get_column(
column_id: int,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""获取栏目详情(后台管理)"""
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
return column.to_dict()
@router.post("/admin/columns")
def create_column(
data: ColumnCreate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""创建栏目(后台管理)"""
# 校验上级栏目
_validate_parent(None, data.parent_id, db)
# 自动生成 slug
slug = data.slug or generate_slug(data.name)
if not slug:
raise HTTPException(status_code=400, detail="URL标识生成失败,请手动填写")
# 检查 slug 唯一性
existing = db.query(ColumnModel).filter(ColumnModel.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="栏目标识已存在")
column = ColumnModel(
name=data.name,
slug=slug,
description=data.description,
parent_id=data.parent_id,
sort_order=data.sort_order,
is_enabled=data.is_enabled,
display_mode=data.display_mode,
layout_style=data.layout_style,
icon_name=data.icon_name,
)
db.add(column)
db.commit()
db.refresh(column)
return column.to_dict()
@router.put("/admin/columns/{column_id}")
def update_column(
column_id: int,
data: ColumnUpdate,
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""更新栏目(后台管理)"""
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
# 校验上级栏目
if data.parent_id is not None:
_validate_parent(column, data.parent_id, db)
# 检查 slug 唯一性
slug = data.slug
if slug is not None:
if not slug.strip():
slug = generate_slug(data.name or column.name)
if slug != column.slug:
existing = db.query(ColumnModel).filter(ColumnModel.slug == slug).first()
if existing:
raise HTTPException(status_code=400, detail="栏目标识已存在")
update_data = data.model_dump(exclude_unset=True)
if slug is not None:
update_data["slug"] = slug
for key, value in update_data.items():
setattr(column, key, value)
db.commit()
db.refresh(column)
return column.to_dict()
@router.delete("/admin/columns/{column_id}")
def delete_column(
column_id: int,
force: bool = Query(default=False, description="强制删除关联内容"),
db: Session = Depends(get_db),
current_admin=Depends(get_current_admin),
):
"""删除栏目(后台管理)"""
column = db.query(ColumnModel).filter(ColumnModel.id == column_id).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
# 检查是否有子栏目
child_count = db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).count()
if child_count > 0:
if not force:
raise HTTPException(
status_code=400,
detail=f"该栏目存在 {child_count} 个子栏目,请先删除子栏目或勾选强制删除"
)
# 强制删除:递归删除所有子栏目
db.query(ColumnModel).filter(ColumnModel.parent_id == column_id).delete(synchronize_session=False)
# TODO: 检查关联的内容数量(等 articles 模型创建后补充)
# content_count = db.query(Article).filter(Article.column_id == column_id).count()
# if content_count > 0:
# if not force:
# raise HTTPException(status_code=400, detail=f"该栏目下还有 {content_count} 条内容,是否强制删除?")
# db.query(Article).filter(Article.column_id == column_id).delete(synchronize_session=False)
db.delete(column)
db.commit()
return {"message": "删除成功"}
# ========== 访客端 Public API ==========
@router.get("/public/columns")
def list_public_columns(
db: Session = Depends(get_db),
):
"""访客端:获取启用的栏目树"""
columns = db.query(ColumnModel).filter(
ColumnModel.is_enabled == True
).order_by(ColumnModel.sort_order.asc()).all()
return {"items": _build_tree(columns), "total": len(columns)}
@router.get("/public/columns/{slug}")
def get_public_column(
slug: str,
db: Session = Depends(get_db),
):
"""访客端:获取栏目详情(包含子栏目)"""
column = db.query(ColumnModel).filter(
ColumnModel.slug == slug,
ColumnModel.is_enabled == True
).first()
if not column:
raise HTTPException(status_code=404, detail="栏目不存在")
result = column.to_dict()
# 加载子栏目
children = db.query(ColumnModel).filter(
ColumnModel.parent_id == column.id,
ColumnModel.is_enabled == True
).order_by(ColumnModel.sort_order.asc()).all()
result["children"] = [c.to_dict() for c in children]
# 加载栏目下的内容数量
from app.models.article import Article
article_count = db.query(Article).filter(
Article.column_id == column.id,
Article.status == "published"
).count()
result["article_count"] = article_count
return result