1084388fec
选题有关联 content_tasks/article/publish_record/metrics 表, 外键约束阻止删除。改为先级联删除关联数据再删选题。 前端同时显示服务端返回的具体错误信息,而不是笼统的 删除失败。
325 lines
11 KiB
Python
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] |