Files
yu-zhi-ran/platform/backend/app/api/topics.py
T
Yuzhiran Dev 1084388fec fix(topics): 外键约束导致 delete 400
选题有关联 content_tasks/article/publish_record/metrics 表,
外键约束阻止删除。改为先级联删除关联数据再删选题。

前端同时显示服务端返回的具体错误信息,而不是笼统的 删除失败。
2026-06-01 12:08:51 +08:00

325 lines
11 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func
from typing import List, Optional, Dict, Any
from datetime import datetime, date
from pathlib import Path
from ..database import get_db
from ..models import Topic, TopicField, TopicConfigField, Article, ContentMetrics
from ..schemas import (
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
)
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
PROJECT_ROOT = Path(__file__).parent.parent.parent
def _check_org(topic: Topic, current_user, db: Session):
"""Verify topic belongs to user's org (unless admin)."""
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Topic not found")
return topic
@router.get("", response_model=List[TopicResponse])
def list_topics(
field_id: Optional[int] = None,
status: Optional[str] = None,
today: Optional[bool] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]),
order: str = Query("desc", enum=["asc", "desc"]),
limit: int = Query(50, le=200),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
db.expire_all()
query = db.query(Topic).options(joinedload(Topic.field))
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter(of)
if field_id:
query = query.filter(Topic.field_id == field_id)
if status:
query = query.filter(Topic.status == status)
if today:
query = query.filter(func.date(Topic.created_at) == date.today())
if tag:
query = query.filter(Topic.tags.contains([tag]))
if search:
query = query.filter(Topic.title.contains(search))
sort_col = getattr(Topic, sort_by, Topic.priority_score)
if order == "desc":
query = query.order_by(sort_col.desc())
else:
query = query.order_by(sort_col.asc())
return query.offset(offset).limit(limit).all()
@router.get("/stats")
def topic_stats(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
base_q = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
base_q = base_q.filter(of)
total = base_q.count()
raw = base_q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
by_status = {s: c for s, c in raw}
today = date.today()
today_count = base_q.filter(func.date(Topic.created_at) == today).count()
published = base_q.filter(Topic.status.in_(["published", "已发布"])).count()
metrics_count = db.query(ContentMetrics).count()
return {
"total": total,
"by_status": by_status,
"published": published,
"today_created": today_count,
"metrics_count": metrics_count
}
@router.post("", response_model=TopicResponse)
def create_topic(
data: TopicCreate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
topic_id = data.id
if not topic_id:
max_topic = db.query(Topic).order_by(Topic.id.desc()).first()
if max_topic and max_topic.id.startswith("T"):
try:
num = int(max_topic.id[1:]) + 1
topic_id = f"T{num:03d}"
except:
topic_id = f"T{datetime.now().strftime('%m%d%H%M')}"
else:
topic_id = f"T{datetime.now().strftime('%m%d%H%M')}"
field_name = None
if data.field_id:
field = db.query(TopicField).filter(TopicField.id == data.field_id).first()
if field:
field_name = field.name
topic = Topic(
id=topic_id,
field_id=data.field_id,
field_name=field_name,
org_id=current_user.org_id or "default",
title=data.title,
format=data.format,
core_concept=data.core_concept,
audience_pain=data.audience_pain,
unique_angle=data.unique_angle,
priority=data.priority,
status="pending",
tags=data.tags or [],
custom_data=data.custom_data or {},
scoring_data=data.scoring_data or {},
)
db.add(topic)
db.commit()
db.refresh(topic)
return topic
@router.get("/{topic_id}", response_model=TopicResponse)
def get_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).options(joinedload(Topic.field)).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
return topic
@router.put("/{topic_id}", response_model=TopicResponse)
def update_topic(
topic_id: str,
data: TopicUpdate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
if data.field_id is not None:
topic.field_id = data.field_id
if data.field_id:
field = db.query(TopicField).filter(TopicField.id == data.field_id).first()
topic.field_name = field.name if field else None
for k, v in data.model_dump(exclude_unset=True, exclude={"field_id"}).items():
if k == "tags" or k == "custom_data" or k == "scoring_data":
if v is not None:
setattr(topic, k, v)
elif v is not None:
setattr(topic, k, v)
topic.updated_at = datetime.now()
db.commit()
db.refresh(topic)
return topic
@router.delete("/{topic_id}")
def delete_topic(
topic_id: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
from ..models import ContentTask, Article, PublishRecord, ContentMetrics, ContentCalendar
try:
db.query(ContentCalendar).filter(ContentCalendar.topic_id == topic_id).delete()
db.query(ContentTask).filter(ContentTask.topic_id == topic_id).delete()
db.query(Article).filter(Article.topic_id == topic_id).delete()
db.query(PublishRecord).filter(PublishRecord.topic_id == topic_id).delete()
db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).delete()
db.delete(topic)
db.commit()
return {"ok": True}
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=f"删除失败:{str(e)}")
@router.post("/{topic_id}/score")
def score_topic(
topic_id: str,
data: TopicScoreRequest,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.scoring_data = data.scoring_data
if data.scoring_data and topic.field_id:
configs = db.query(TopicConfigField).filter(
TopicConfigField.field_id == topic.field_id
).all()
total_weight = 0
weighted_sum = 0
for cfg in configs:
val = data.scoring_data.get(cfg.key)
if val is not None and cfg.field_type == "number":
if cfg.min_value is not None:
val = max(val, cfg.min_value)
if cfg.max_value is not None:
val = min(val, cfg.max_value)
normalized = (val - cfg.min_value) / (cfg.max_value - cfg.min_value) if cfg.max_value != cfg.min_value else 0.5
weighted_sum += normalized * cfg.weight
total_weight += cfg.weight
if total_weight > 0:
topic.total_score = round(weighted_sum / total_weight * 100, 1)
topic.priority_score = int(topic.total_score)
topic.updated_at = datetime.now()
db.commit()
db.refresh(topic)
return {"priority_score": topic.priority_score, "total_score": topic.total_score}
@router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]])
def get_topic_articles(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
articles = db.query(Article).filter(Article.topic_id == topic_id).all()
return [a.to_dict() if hasattr(a, 'to_dict') else {
"id": a.id, "topic_id": a.topic_id, "platform": a.platform,
"status": a.status, "file_path": a.file_path
} for a in articles]
@router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]])
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all()
return [m.to_dict() for m in metrics]
@router.post("/{topic_id}/lock")
def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.lock_by = current_user.username
topic.lock_at = datetime.now()
db.commit()
return {"ok": True}
@router.post("/{topic_id}/unlock")
def unlock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.lock_by = None
topic.lock_at = None
db.commit()
return {"ok": True}
@router.post("/batch-update-status")
def batch_update_status(
topic_ids: List[str],
status: str,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
q = db.query(Topic).filter(Topic.id.in_(topic_ids))
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
updated = q.update(
{Topic.status: status, Topic.updated_at: datetime.now()},
synchronize_session=False
)
db.commit()
return {"ok": True, "updated": updated}
@router.get("/field-distribution")
def field_distribution(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
q = db.query(Topic.field_name, func.count(Topic.id).label("count"))
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
rows = q.group_by(Topic.field_name).all()
return [{"field": r.field_name or "未分类", "count": r.count} for r in rows]