Files
yu-zhi-ran/platform/backend/app/api/topics.py
T
Yuzhiran Dev 9c37c9a574 feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化
Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列
测试: tests/test_phase_upgrades.py 97项全覆盖
CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式
修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
2026-05-17 06:56:53 +08:00

346 lines
12 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, PublishRecord, ContentMetrics
from ..schemas import (
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
PublishRequest, PublishActionRequest, PublishRecordResponse
)
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)
try:
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.post("/{topic_id}/publish")
def publish_topic(topic_id: str, req: PublishRequest, 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 topic.status not in ("pending", "ready", "draft"):
raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布")
topic.status = "published"
topic.published_at = date.today()
topic.updated_at = datetime.now()
topic.platform_urls = req.platform_urls
record = PublishRecord(
topic_id=topic_id,
platform="all",
action="publish",
status="success",
description=f"选题 {topic_id} 已发布"
)
db.add(record)
db.commit()
return {"ok": True, "topic_id": topic_id}
@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]