Files
yu-zhi-ran/platform/backend/app/api/metrics.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

305 lines
11 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from typing import List, Optional
from datetime import datetime, timedelta, date
import logging
from ..database import get_db
from ..models import ContentMetrics, Topic, ContentCalendar
from ..schemas import (
ContentMetricsCreate, ContentMetricsUpdate, ContentMetricsResponse,
MetricsDashboard
)
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/metrics", tags=["metrics"])
@router.get("/dashboard", response_model=MetricsDashboard)
def get_dashboard(
days: int = Query(30, ge=1, le=365),
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
since = datetime.now() - timedelta(days=days)
topic_base = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
topic_base = topic_base.filter(of)
total_topics = topic_base.count()
raw_status = topic_base.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
topics_by_status = {}
for s, cnt in raw_status:
topics_by_status[s] = cnt
metrics_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
metrics_q = metrics_q.filter(of)
total_published = metrics_q.filter(ContentMetrics.views > 0).count()
all_metrics = metrics_q.filter(
ContentMetrics.created_at >= since
).all()
total_views = sum(m.views for m in all_metrics)
total_likes = sum(m.likes for m in all_metrics)
engagement_rates = [m.engagement_rate for m in all_metrics if m.views > 0]
avg_engagement = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0
top_q = db.query(
ContentMetrics.topic_id,
func.sum(ContentMetrics.views).label("total_views"),
func.sum(ContentMetrics.likes).label("total_likes")
).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
top_q = top_q.filter(of)
top_topics_data = top_q.filter(
ContentMetrics.created_at >= since
).group_by(ContentMetrics.topic_id).order_by(desc("total_views")).limit(10).all()
top_topics = []
for row in top_topics_data:
topic = db.query(Topic).filter(Topic.id == row.topic_id).first()
top_topics.append({
"topic_id": row.topic_id,
"title": topic.title if topic else row.topic_id,
"total_views": row.total_views or 0,
"total_likes": row.total_likes or 0,
})
recent_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
recent_q = recent_q.filter(of)
recent_metrics = recent_q.order_by(
ContentMetrics.created_at.desc()
).limit(10).all()
return MetricsDashboard(
total_topics=total_topics,
topics_by_status=topics_by_status,
total_published=total_published,
total_views=total_views,
total_likes=total_likes,
avg_engagement_rate=round(avg_engagement, 2),
top_topics=top_topics,
recent_metrics=[ContentMetricsResponse.model_validate(m) for m in recent_metrics]
)
@router.get("/trend")
def get_trend(
days: int = Query(30, ge=1, le=365),
group_by: str = Query("day", enum=["day", "week", "month"]),
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
since = datetime.now() - timedelta(days=days)
if group_by == "day":
date_format = func.date(ContentMetrics.created_at)
else:
date_format = func.date_trunc(group_by, ContentMetrics.created_at)
q = db.query(
date_format.label("period"),
func.sum(ContentMetrics.views).label("views"),
func.sum(ContentMetrics.likes).label("likes"),
func.sum(ContentMetrics.comments).label("comments"),
func.count(ContentMetrics.id).label("count")
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
q = q.filter(of_m)
rows = q.filter(
ContentMetrics.created_at >= since
).group_by(date_format).order_by(date_format).all()
return [
{
"period": str(row.period),
"views": row.views or 0,
"likes": row.likes or 0,
"comments": row.comments or 0,
"count": row.count or 0
}
for row in rows
]
@router.get("/entries", response_model=List[ContentMetricsResponse])
def list_metrics(
topic_id: Optional[str] = None,
platform: Optional[str] = None,
limit: int = Query(50, le=200),
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
query = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
query = query.filter(of_m)
if topic_id:
query = query.filter(ContentMetrics.topic_id == topic_id)
if platform:
query = query.filter(ContentMetrics.platform == platform)
return query.order_by(ContentMetrics.created_at.desc()).limit(limit).all()
@router.post("/entries", response_model=ContentMetricsResponse)
def create_metric(
data: ContentMetricsCreate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == data.topic_id,
ContentMetrics.platform == data.platform
).first()
if existing:
for k, v in data.model_dump(exclude_unset=True).items():
if k != "topic_id" and k != "platform":
setattr(existing, k, v)
existing.last_fetched = datetime.now()
db.commit()
db.refresh(existing)
return existing
metric = ContentMetrics(**data.model_dump(), last_fetched=datetime.now())
db.add(metric)
db.commit()
db.refresh(metric)
return metric
@router.put("/entries/{metric_id}", response_model=ContentMetricsResponse)
def update_metric(
metric_id: int,
data: ContentMetricsUpdate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first()
if not metric:
raise HTTPException(status_code=404, detail="数据记录不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(metric, k, v)
metric.last_fetched = datetime.now()
db.commit()
db.refresh(metric)
return metric
@router.delete("/entries/{metric_id}")
def delete_metric(
metric_id: int,
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first()
if not metric:
raise HTTPException(status_code=404, detail="数据记录不存在")
db.delete(metric)
db.commit()
return {"ok": True}
@router.get("/topics/{topic_id}", response_model=List[ContentMetricsResponse])
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="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
return db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic_id
).order_by(ContentMetrics.created_at.desc()).all()
@router.get("/by-platform")
def get_metrics_by_platform(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
q = db.query(
ContentMetrics.platform,
func.sum(ContentMetrics.views).label("total_views"),
func.sum(ContentMetrics.likes).label("total_likes"),
func.sum(ContentMetrics.comments).label("total_comments"),
func.count(ContentMetrics.id).label("count")
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
q = q.filter(of_m)
rows = q.group_by(ContentMetrics.platform).all()
return [
{
"platform": row.platform,
"total_views": row.total_views or 0,
"total_likes": row.total_likes or 0,
"total_comments": row.total_comments or 0,
"count": row.count or 0,
"avg_views": (row.total_views or 0) / (row.count or 1),
"avg_likes": (row.total_likes or 0) / (row.count or 1),
}
for row in rows
]
@router.get("/recommend-topics")
def recommend_topics_from_metrics(
limit: int = Query(10, le=50),
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
try:
hp_q = db.query(
ContentMetrics.topic_id,
func.avg(ContentMetrics.engagement_rate).label("avg_engagement"),
func.max(ContentMetrics.views).label("max_views")
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
hp_q = hp_q.filter(of_m)
high_performing = hp_q.group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
recommendations = []
for row in high_performing:
topic = db.query(Topic).filter(Topic.id == row.topic_id).first()
if not topic:
continue
metrics = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == row.topic_id
).all()
recommendations.append({
"topic_id": row.topic_id,
"title": topic.title,
"field": topic.field_name,
"status": topic.status,
"avg_engagement": round(float(row.avg_engagement), 2) if row.avg_engagement else 0,
"max_views": int(row.max_views) if row.max_views else 0,
"platforms": list(set(m.platform for m in metrics)),
"reason": f"平均互动率 {round(float(row.avg_engagement), 2) if row.avg_engagement else 0}%,最高阅读 {int(row.max_views) if row.max_views else 0}"
})
return recommendations[:limit]
except Exception as e:
logging.getLogger(__name__).exception(f"推荐选题失败: {e}")
return []