feat: 全面升级项目架构 - PostgreSQL迁移 + 配置化改造
主要变更: - 数据库: SQLite → PostgreSQL (yzr_nr) - 选题系统: 硬编码字段 → 配置化 (TopicField/TopicConfigField/TopicStatusConfig) - 新增模型: ContentCalendar, ContentMetrics, MediaAsset, PlatformConfig, ContentTask - 新增 API: topic-config, calendar, metrics, assets, tasks, platform-config - 数据迁移: 现有选题数据迁移到新 schema (field_id/tags/custom_data/scoring_data) - 初始化数据: 10个领域, 5种状态, 3个平台配置 服务运行: http://localhost:8001 默认账号: admin / admin123
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import MediaAsset
|
||||
from ..schemas import MediaAssetCreate, MediaAssetUpdate, MediaAssetResponse
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/assets", tags=["assets"])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||
if os.getenv('PROJECT_ROOT'):
|
||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||
UPLOAD_DIR = PROJECT_ROOT / "content" / "images"
|
||||
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml"}
|
||||
|
||||
|
||||
@router.get("", response_model=List[MediaAssetResponse])
|
||||
def list_assets(
|
||||
file_type: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
topic_id: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = Query(50, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(MediaAsset)
|
||||
|
||||
if file_type:
|
||||
query = query.filter(MediaAsset.file_type == file_type)
|
||||
if tag:
|
||||
query = query.filter(MediaAsset.tags.contains([tag]))
|
||||
if topic_id:
|
||||
query = query.filter(MediaAsset.topic_ids.contains([topic_id]))
|
||||
if search:
|
||||
query = query.filter(
|
||||
(MediaAsset.filename.contains(search)) |
|
||||
(MediaAsset.alt_text.contains(search))
|
||||
)
|
||||
|
||||
return query.order_by(MediaAsset.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
def list_tags(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
assets = db.query(MediaAsset.tags).all()
|
||||
all_tags = set()
|
||||
for a in assets:
|
||||
if a[0]:
|
||||
all_tags.update(a[0])
|
||||
return sorted(all_tags)
|
||||
|
||||
|
||||
@router.get("/counts")
|
||||
def get_counts(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
total = db.query(MediaAsset).count()
|
||||
by_type = {}
|
||||
rows = db.query(MediaAsset.file_type, db.func.count(MediaAsset.id)).group_by(MediaAsset.file_type).all()
|
||||
for ftype, cnt in rows:
|
||||
by_type[ftype] = cnt
|
||||
return {"total": total, "by_type": by_type}
|
||||
|
||||
|
||||
@router.post("/upload", response_model=MediaAssetResponse)
|
||||
async def upload_asset(
|
||||
file: UploadFile = File(...),
|
||||
tags: Optional[str] = Query(None, description="逗号分隔的标签"),
|
||||
alt_text: Optional[str] = Query(None),
|
||||
topic_ids: Optional[str] = Query(None, description="逗号分隔的选题ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
if file.content_type not in ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(status_code=400, detail=f"不支持的文件类型: {file.content_type}")
|
||||
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
suffix = Path(file.filename).suffix or ""
|
||||
unique_name = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||
file_path = UPLOAD_DIR / unique_name
|
||||
|
||||
content = await file.read()
|
||||
file_size = len(content)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
file_type = file.content_type.split("/")[0]
|
||||
if file_type not in ("image", "video", "application"):
|
||||
if suffix in (".pdf", ".doc", ".docx", ".ppt", ".pptx"):
|
||||
file_type = "document"
|
||||
elif suffix in (".mp4", ".mov", ".avi"):
|
||||
file_type = "video"
|
||||
else:
|
||||
file_type = "image"
|
||||
|
||||
parsed_tags = [t.strip() for t in tags.split(",")] if tags else []
|
||||
parsed_topic_ids = [t.strip() for t in topic_ids.split(",")] if topic_ids else []
|
||||
|
||||
asset = MediaAsset(
|
||||
filename=file.filename,
|
||||
file_path=str(file_path),
|
||||
file_type=file_type,
|
||||
mime_type=file.content_type,
|
||||
size=file_size,
|
||||
alt_text=alt_text,
|
||||
tags=parsed_tags,
|
||||
topic_ids=parsed_topic_ids,
|
||||
uploaded_by=current_user.username
|
||||
)
|
||||
db.add(asset)
|
||||
db.commit()
|
||||
db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.put("/{asset_id}", response_model=MediaAssetResponse)
|
||||
def update_asset(
|
||||
asset_id: int,
|
||||
data: MediaAssetUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="素材不存在")
|
||||
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(asset, k, v)
|
||||
db.commit()
|
||||
db.refresh(asset)
|
||||
return asset
|
||||
|
||||
|
||||
@router.delete("/{asset_id}")
|
||||
def delete_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="素材不存在")
|
||||
|
||||
if os.path.exists(asset.file_path):
|
||||
try:
|
||||
os.remove(asset.file_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
db.delete(asset)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/{asset_id}/use")
|
||||
def increment_usage(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
asset = db.query(MediaAsset).filter(MediaAsset.id == asset_id).first()
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="素材不存在")
|
||||
asset.usage_count = (asset.usage_count or 0) + 1
|
||||
db.commit()
|
||||
return {"ok": True, "usage_count": asset.usage_count}
|
||||
@@ -0,0 +1,188 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, date
|
||||
from calendar import monthrange
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import ContentCalendar, Topic
|
||||
from ..schemas import (
|
||||
ContentCalendarCreate, ContentCalendarUpdate, ContentCalendarResponse,
|
||||
ContentCalendarBase
|
||||
)
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[ContentCalendarResponse])
|
||||
def get_calendar(
|
||||
year: int = Query(...),
|
||||
month: int = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
start = date(year, month, 1)
|
||||
last_day = monthrange(year, month)[1]
|
||||
end = date(year, month, last_day)
|
||||
return db.query(ContentCalendar).filter(
|
||||
ContentCalendar.planned_date >= start,
|
||||
ContentCalendar.planned_date <= end
|
||||
).order_by(ContentCalendar.planned_date).all()
|
||||
|
||||
|
||||
@router.get("/entries", response_model=List[ContentCalendarResponse])
|
||||
def list_entries(
|
||||
start_date: Optional[date] = None,
|
||||
end_date: Optional[date] = None,
|
||||
status: 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(ContentCalendar)
|
||||
if start_date:
|
||||
query = query.filter(ContentCalendar.planned_date >= start_date)
|
||||
if end_date:
|
||||
query = query.filter(ContentCalendar.planned_date <= end_date)
|
||||
if status:
|
||||
query = query.filter(ContentCalendar.status == status)
|
||||
if platform:
|
||||
query = query.filter(ContentCalendar.platform == platform)
|
||||
return query.order_by(ContentCalendar.planned_date.desc()).limit(limit).all()
|
||||
|
||||
|
||||
@router.post("/entries", response_model=ContentCalendarResponse)
|
||||
def create_entry(
|
||||
data: ContentCalendarCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
if data.topic_id:
|
||||
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
entry = ContentCalendar(**data.model_dump())
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
if data.topic_id and data.title:
|
||||
if not topic.title or topic.title != data.title:
|
||||
topic.title = data.title
|
||||
db.commit()
|
||||
return entry
|
||||
|
||||
|
||||
@router.put("/entries/{entry_id}", response_model=ContentCalendarResponse)
|
||||
def update_entry(
|
||||
entry_id: int,
|
||||
data: ContentCalendarUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
||||
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(entry, k, v)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
|
||||
if entry.topic_id:
|
||||
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
|
||||
if topic and entry.status == "published" and not entry.published_date:
|
||||
entry.published_date = date.today()
|
||||
if topic.status == "pending" or topic.status == "ready":
|
||||
topic.status = "published"
|
||||
topic.published_at = date.today()
|
||||
topic.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@router.delete("/entries/{entry_id}")
|
||||
def delete_entry(
|
||||
entry_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/entries/bind-topic")
|
||||
def bind_topic_to_entry(
|
||||
entry_id: int,
|
||||
topic_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="日历条目不存在")
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
entry.topic_id = topic_id
|
||||
entry.title = topic.title
|
||||
if topic.field_name:
|
||||
entry.field_id = topic.field_id
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def calendar_stats(
|
||||
year: int = Query(...),
|
||||
month: int = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
start = date(year, month, 1)
|
||||
last_day = monthrange(year, month)[1]
|
||||
end = date(year, month, last_day)
|
||||
entries = db.query(ContentCalendar).filter(
|
||||
ContentCalendar.planned_date >= start,
|
||||
ContentCalendar.planned_date <= end
|
||||
).all()
|
||||
|
||||
stats = {"total": len(entries), "planned": 0, "published": 0, "delayed": 0, "cancelled": 0}
|
||||
for e in entries:
|
||||
if e.status in stats:
|
||||
stats[e.status] += 1
|
||||
return stats
|
||||
|
||||
|
||||
@router.post("/entries/from-topic")
|
||||
def create_from_topic(
|
||||
topic_id: str,
|
||||
planned_date: date,
|
||||
platform: str = Query(...),
|
||||
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="选题不存在")
|
||||
entry = ContentCalendar(
|
||||
topic_id=topic_id,
|
||||
field_id=topic.field_id,
|
||||
title=topic.title,
|
||||
planned_date=planned_date,
|
||||
platform=platform,
|
||||
created_by=current_user.username
|
||||
)
|
||||
db.add(entry)
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return entry
|
||||
@@ -0,0 +1,269 @@
|
||||
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
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import ContentMetrics, Topic, ContentCalendar
|
||||
from ..schemas import (
|
||||
ContentMetricsCreate, ContentMetricsUpdate, ContentMetricsResponse,
|
||||
MetricsDashboard
|
||||
)
|
||||
from .auth import get_current_user
|
||||
|
||||
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)
|
||||
|
||||
total_topics = db.query(Topic).count()
|
||||
|
||||
raw_status = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
topics_by_status = {}
|
||||
for s, cnt in raw_status:
|
||||
topics_by_status[s] = cnt
|
||||
|
||||
total_published = db.query(ContentMetrics).filter(
|
||||
ContentMetrics.views > 0
|
||||
).count()
|
||||
|
||||
all_metrics = db.query(ContentMetrics).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_topics_data = db.query(
|
||||
ContentMetrics.topic_id,
|
||||
func.sum(ContentMetrics.views).label("total_views"),
|
||||
func.sum(ContentMetrics.likes).label("total_likes")
|
||||
).join(Topic).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_metrics = db.query(ContentMetrics).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)
|
||||
|
||||
rows = 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")
|
||||
).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)
|
||||
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="选题不存在")
|
||||
|
||||
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="选题不存在")
|
||||
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)
|
||||
):
|
||||
rows = 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")
|
||||
).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)
|
||||
):
|
||||
high_performing = db.query(
|
||||
ContentMetrics.topic_id,
|
||||
func.avg(ContentMetrics.engagement_rate).label("avg_engagement"),
|
||||
func.max(ContentMetrics.views).label("max_views")
|
||||
).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(row.avg_engagement, 2) if row.avg_engagement else 0,
|
||||
"max_views": row.max_views or 0,
|
||||
"platforms": list(set(m.platform for m in metrics)),
|
||||
"reason": f"平均互动率 {round(row.avg_engagement, 2)}%,最高阅读 {row.max_views}"
|
||||
})
|
||||
|
||||
return recommendations[:limit]
|
||||
@@ -0,0 +1,68 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import PlatformConfig
|
||||
from ..schemas import PlatformConfigCreate, PlatformConfigUpdate, PlatformConfigResponse
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/platform-config", tags=["platform-config"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[PlatformConfigResponse])
|
||||
def list_platforms(
|
||||
active_only: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(PlatformConfig)
|
||||
if active_only:
|
||||
query = query.filter(PlatformConfig.is_active == True)
|
||||
return query.order_by(PlatformConfig.id).all()
|
||||
|
||||
|
||||
@router.post("", response_model=PlatformConfigResponse)
|
||||
def create_platform(
|
||||
data: PlatformConfigCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
existing = db.query(PlatformConfig).filter(PlatformConfig.platform == data.platform).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"平台 '{data.platform}' 已存在")
|
||||
p = PlatformConfig(**data.model_dump())
|
||||
db.add(p)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
@router.get("/{platform}", response_model=PlatformConfigResponse)
|
||||
def get_platform(platform: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
p = db.query(PlatformConfig).filter(PlatformConfig.platform == platform).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="平台不存在")
|
||||
return p
|
||||
|
||||
|
||||
@router.put("/{platform}", response_model=PlatformConfigResponse)
|
||||
def update_platform(platform: str, data: PlatformConfigUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
p = db.query(PlatformConfig).filter(PlatformConfig.platform == platform).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="平台不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(p, k, v)
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
return p
|
||||
|
||||
|
||||
@router.delete("/{platform}")
|
||||
def delete_platform(platform: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||
p = db.query(PlatformConfig).filter(PlatformConfig.platform == platform).first()
|
||||
if not p:
|
||||
raise HTTPException(status_code=404, detail="平台不存在")
|
||||
p.is_active = False
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
@@ -0,0 +1,234 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import ContentTask, Topic
|
||||
from ..schemas import ContentTaskCreate, ContentTaskResponse
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[ContentTaskResponse])
|
||||
def list_tasks(
|
||||
status: Optional[str] = None,
|
||||
topic_id: Optional[str] = None,
|
||||
stage: Optional[str] = None,
|
||||
limit: int = Query(50, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(ContentTask)
|
||||
if status:
|
||||
query = query.filter(ContentTask.status == status)
|
||||
if topic_id:
|
||||
query = query.filter(ContentTask.topic_id == topic_id)
|
||||
if stage:
|
||||
query = query.filter(ContentTask.stage == stage)
|
||||
return query.order_by(ContentTask.created_at.desc()).limit(limit).all()
|
||||
|
||||
|
||||
@router.get("/active")
|
||||
def get_active_tasks(
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
return db.query(ContentTask).filter(
|
||||
ContentTask.status == "running"
|
||||
).order_by(ContentTask.started_at.desc()).all()
|
||||
|
||||
|
||||
@router.post("", response_model=ContentTaskResponse)
|
||||
def create_task(
|
||||
data: ContentTaskCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
if data.topic_id:
|
||||
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="选题不存在")
|
||||
|
||||
task_id = f"task_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
task = ContentTask(
|
||||
task_id=task_id,
|
||||
topic_id=data.topic_id,
|
||||
stage=data.stage,
|
||||
status="pending",
|
||||
created_by=data.created_by or current_user.username
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=ContentTaskResponse)
|
||||
def get_task(
|
||||
task_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
return task
|
||||
|
||||
|
||||
@router.put("/{task_id}/start")
|
||||
def start_task(
|
||||
task_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
from datetime import datetime
|
||||
task.status = "running"
|
||||
task.started_at = datetime.now()
|
||||
task.message = "任务已启动"
|
||||
task.progress = 0
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.put("/{task_id}/progress")
|
||||
def update_progress(
|
||||
task_id: str,
|
||||
progress: int = Query(..., ge=0, le=100),
|
||||
message: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
task.progress = progress
|
||||
if message:
|
||||
task.message = message
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.put("/{task_id}/complete")
|
||||
def complete_task(
|
||||
task_id: str,
|
||||
result_data: dict = None,
|
||||
message: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
from datetime import datetime
|
||||
task.status = "completed"
|
||||
task.finished_at = datetime.now()
|
||||
task.progress = 100
|
||||
if message:
|
||||
task.message = message
|
||||
if result_data:
|
||||
task.result_data = result_data
|
||||
if task.started_at:
|
||||
task.duration = int((task.finished_at - task.started_at).total_seconds())
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.put("/{task_id}/fail")
|
||||
def fail_task(
|
||||
task_id: str,
|
||||
error_msg: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
from datetime import datetime
|
||||
task.status = "failed"
|
||||
task.finished_at = datetime.now()
|
||||
task.error_msg = error_msg
|
||||
if task.started_at:
|
||||
task.duration = int((task.finished_at - task.started_at).total_seconds())
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return task
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
def cancel_task(
|
||||
task_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
task = db.query(ContentTask).filter(ContentTask.task_id == task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
task.status = "cancelled"
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/run-creator")
|
||||
def run_creator_task(
|
||||
topic_id: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
task_id = f"task_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
task = ContentTask(
|
||||
task_id=task_id,
|
||||
topic_id=topic_id,
|
||||
stage="creator",
|
||||
status="running",
|
||||
started_at=datetime.now(),
|
||||
created_by=current_user.username
|
||||
)
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
from ..core.generator import run_creator
|
||||
|
||||
def _run():
|
||||
try:
|
||||
result = run_creator(topic_id)
|
||||
from datetime import datetime
|
||||
task.status = "completed"
|
||||
task.finished_at = datetime.now()
|
||||
task.progress = 100
|
||||
task.message = "创作完成"
|
||||
task.result_data = result or {}
|
||||
if task.started_at:
|
||||
task.duration = int((task.finished_at - task.started_at).total_seconds())
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
from datetime import datetime
|
||||
task.status = "failed"
|
||||
task.finished_at = datetime.now()
|
||||
task.error_msg = str(e)
|
||||
if task.started_at:
|
||||
task.duration = int((task.finished_at - task.started_at).total_seconds())
|
||||
db.commit()
|
||||
|
||||
thread = threading.Thread(target=_run)
|
||||
thread.start()
|
||||
|
||||
return task
|
||||
@@ -0,0 +1,174 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import TopicField, TopicConfigField
|
||||
from ..schemas import (
|
||||
TopicFieldBase, TopicFieldResponse,
|
||||
TopicConfigFieldBase, TopicConfigFieldResponse
|
||||
)
|
||||
from .auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/api/topic-config", tags=["topic-config"])
|
||||
|
||||
|
||||
@router.get("/fields", response_model=List[TopicFieldResponse])
|
||||
def list_fields(
|
||||
include_inactive: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
query = db.query(TopicField)
|
||||
if not include_inactive:
|
||||
query = query.filter(TopicField.is_active == True)
|
||||
return query.order_by(TopicField.sort_order, TopicField.id).all()
|
||||
|
||||
|
||||
@router.post("/fields", response_model=TopicFieldResponse)
|
||||
def create_field(
|
||||
data: TopicFieldBase,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
existing = db.query(TopicField).filter(TopicField.name == data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"领域 '{data.name}' 已存在")
|
||||
field = TopicField(**data.model_dump())
|
||||
db.add(field)
|
||||
db.commit()
|
||||
db.refresh(field)
|
||||
return field
|
||||
|
||||
|
||||
@router.put("/fields/{field_id}", response_model=TopicFieldResponse)
|
||||
def update_field(
|
||||
field_id: int,
|
||||
data: TopicFieldBase,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||
if not field:
|
||||
raise HTTPException(status_code=404, detail="领域不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(field, k, v)
|
||||
db.commit()
|
||||
db.refresh(field)
|
||||
return field
|
||||
|
||||
|
||||
@router.delete("/fields/{field_id}")
|
||||
def delete_field(
|
||||
field_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||
if not field:
|
||||
raise HTTPException(status_code=404, detail="领域不存在")
|
||||
field.is_active = False
|
||||
db.commit()
|
||||
return {"ok": True, "message": "领域已删除"}
|
||||
|
||||
|
||||
@router.get("/fields/{field_id}/scoring", response_model=List[TopicConfigFieldResponse])
|
||||
def get_scoring_fields(
|
||||
field_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||
if not field:
|
||||
raise HTTPException(status_code=404, detail="领域不存在")
|
||||
return db.query(TopicConfigField).filter(
|
||||
TopicConfigField.field_id == field_id
|
||||
).order_by(TopicConfigField.sort_order).all()
|
||||
|
||||
|
||||
@router.post("/fields/{field_id}/scoring", response_model=TopicConfigFieldResponse)
|
||||
def create_scoring_field(
|
||||
field_id: int,
|
||||
data: TopicConfigFieldBase,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||
if not field:
|
||||
raise HTTPException(status_code=404, detail="领域不存在")
|
||||
|
||||
existing = db.query(TopicConfigField).filter(
|
||||
TopicConfigField.field_id == field_id,
|
||||
TopicConfigField.key == data.key
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"字段 '{data.key}' 已存在")
|
||||
|
||||
config = TopicConfigField(field_id=field_id, **data.model_dump())
|
||||
db.add(config)
|
||||
db.commit()
|
||||
db.refresh(config)
|
||||
return config
|
||||
|
||||
|
||||
@router.put("/scoring/{config_id}", response_model=TopicConfigFieldResponse)
|
||||
def update_scoring_field(
|
||||
config_id: int,
|
||||
data: TopicConfigFieldBase,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
cfg = db.query(TopicConfigField).filter(TopicConfigField.id == config_id).first()
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="字段不存在")
|
||||
for k, v in data.model_dump(exclude_unset=True).items():
|
||||
setattr(cfg, k, v)
|
||||
db.commit()
|
||||
db.refresh(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
@router.delete("/scoring/{config_id}")
|
||||
def delete_scoring_field(
|
||||
config_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
cfg = db.query(TopicConfigField).filter(TopicConfigField.id == config_id).first()
|
||||
if not cfg:
|
||||
raise HTTPException(status_code=404, detail="字段不存在")
|
||||
db.delete(cfg)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/fields/{field_id}/scoring/batch", response_model=List[TopicConfigFieldResponse])
|
||||
def batch_create_scoring_fields(
|
||||
field_id: int,
|
||||
fields: List[TopicConfigFieldBase],
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user)
|
||||
):
|
||||
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||
if not field:
|
||||
raise HTTPException(status_code=404, detail="领域不存在")
|
||||
|
||||
results = []
|
||||
for f in fields:
|
||||
existing = db.query(TopicConfigField).filter(
|
||||
TopicConfigField.field_id == field_id,
|
||||
TopicConfigField.key == f.key
|
||||
).first()
|
||||
if existing:
|
||||
for k, v in f.model_dump(exclude_unset=True).items():
|
||||
setattr(existing, k, v)
|
||||
results.append(existing)
|
||||
else:
|
||||
obj = TopicConfigField(field_id=field_id, **f.model_dump())
|
||||
db.add(obj)
|
||||
results.append(obj)
|
||||
db.commit()
|
||||
for r in results:
|
||||
db.refresh(r)
|
||||
return results
|
||||
+254
-138
@@ -1,189 +1,305 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
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, PublishRecord
|
||||
from ..schemas import TopicResponse, PublishRequest, PublishActionRequest, PublishRecordResponse
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
|
||||
|
||||
@router.get("", response_model=List[TopicResponse])
|
||||
def list_topics(
|
||||
status: str = None,
|
||||
field_id: Optional[int] = None,
|
||||
status: Optional[str] = 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)
|
||||
):
|
||||
# 确保读取最新数据,清除会话缓存
|
||||
db.expire_all()
|
||||
query = db.query(Topic)
|
||||
query = db.query(Topic).options(joinedload(Topic.field))
|
||||
|
||||
if field_id:
|
||||
query = query.filter(Topic.field_id == field_id)
|
||||
if status:
|
||||
query = query.filter(Topic.status == status)
|
||||
topics = query.order_by(Topic.priority_score.desc(), Topic.created_at.desc()).all()
|
||||
return topics
|
||||
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)
|
||||
):
|
||||
total = db.query(Topic).count()
|
||||
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||
by_status = {s: c for s, c in raw}
|
||||
|
||||
today = date.today()
|
||||
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
|
||||
|
||||
published = db.query(Topic).filter(Topic.status == "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,
|
||||
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)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
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")
|
||||
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")
|
||||
|
||||
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")
|
||||
db.delete(topic)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@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")
|
||||
|
||||
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)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
if topic.status != "ready":
|
||||
raise HTTPException(status_code=400, detail="Topic not in ready status")
|
||||
|
||||
# 更新选题状态
|
||||
if topic.status not in ("pending", "ready", "draft"):
|
||||
raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布")
|
||||
|
||||
topic.status = "published"
|
||||
topic.published_at = datetime.now().date()
|
||||
topic.published_at = date.today()
|
||||
topic.updated_at = datetime.now()
|
||||
topic.platform_urls = req.platform_urls
|
||||
db.commit()
|
||||
|
||||
# 创建发布记录
|
||||
|
||||
record = PublishRecord(
|
||||
topic_id=topic_id,
|
||||
platform=req.platform,
|
||||
platform="all",
|
||||
action="publish",
|
||||
status="success",
|
||||
operator=req.operator,
|
||||
description=req.description,
|
||||
suggestion=req.suggestion,
|
||||
url=req.url,
|
||||
error_msg=req.error_msg
|
||||
description=f"选题 {topic_id} 已发布"
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return {"message": "Topic marked as published", "topic_id": topic_id, "record_id": record.id}
|
||||
return {"ok": True, "topic_id": topic_id}
|
||||
|
||||
|
||||
# 获取选题的发布记录
|
||||
@router.get("/{topic_id}/publish-records", response_model=List[PublishRecordResponse])
|
||||
def get_publish_records(topic_id: str, db: Session = Depends(get_db)):
|
||||
records = db.query(PublishRecord).filter(PublishRecord.topic_id == topic_id).order_by(PublishRecord.created_at.desc()).all()
|
||||
return records
|
||||
|
||||
|
||||
# 创建新的发布记录(用于手动记录发布情况)
|
||||
@router.post("/{topic_id}/publish-records")
|
||||
def create_publish_record(topic_id: str, req: PublishActionRequest, db: Session = Depends(get_db)):
|
||||
# 验证选题存在
|
||||
@router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]])
|
||||
def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
|
||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if not topic:
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
|
||||
record = PublishRecord(
|
||||
topic_id=topic_id,
|
||||
platform=req.platform or "unknown",
|
||||
action=req.action,
|
||||
status=req.status,
|
||||
operator=req.operator,
|
||||
description=req.description,
|
||||
suggestion=req.suggestion,
|
||||
url=req.url,
|
||||
error_msg=req.error_msg
|
||||
)
|
||||
db.add(record)
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
# 如果操作是发布成功,且platform指定,则更新topic的platform_urls
|
||||
if req.action == "publish" and req.status == "success" and req.platform and req.url:
|
||||
if not topic.platform_urls:
|
||||
topic.platform_urls = {}
|
||||
topic.platform_urls[req.platform] = req.url
|
||||
db.commit()
|
||||
|
||||
return record
|
||||
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.put("/publish-records/{record_id}")
|
||||
def update_publish_record(record_id: int, req: PublishActionRequest, db: Session = Depends(get_db)):
|
||||
record = db.query(PublishRecord).filter(PublishRecord.id == record_id).first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=404, detail="Record not found")
|
||||
|
||||
# 更新字段
|
||||
for field, value in req.dict(exclude_unset=True).items():
|
||||
setattr(record, field, value)
|
||||
record.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return record
|
||||
@router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]])
|
||||
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)):
|
||||
metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all()
|
||||
return [m.to_dict() for m in metrics]
|
||||
|
||||
|
||||
@router.get("/{topic_id}/preview")
|
||||
def preview_topic(topic_id: str, platform: str = Query("zhihu", regex="^(zhihu|wechat|xiaohongshu)$")):
|
||||
"""
|
||||
预览某选题在指定平台的HTML内容。
|
||||
查找最近发布的release文件。
|
||||
"""
|
||||
# 查找最近的发布包
|
||||
releases_dir = PROJECT_ROOT / "automation" / "data" / "releases"
|
||||
if not releases_dir.exists():
|
||||
raise HTTPException(status_code=404, detail="No releases found")
|
||||
|
||||
# 按日期倒序查找
|
||||
dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True)
|
||||
found = None
|
||||
for dt in dates:
|
||||
file_path = releases_dir / dt / platform / f"{platform}_{topic_id}_{platform}.html"
|
||||
if file_path.exists():
|
||||
found = file_path
|
||||
break
|
||||
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail=f"Preview not found for topic {topic_id} on {platform}")
|
||||
|
||||
content = found.read_text(encoding="utf-8")
|
||||
return {"topic_id": topic_id, "platform": platform, "html": content}
|
||||
|
||||
|
||||
@router.get("/{topic_id}/packages")
|
||||
def list_packages(topic_id: str):
|
||||
"""
|
||||
列出某选题的所有发布包(HTML文件)。
|
||||
"""
|
||||
releases_dir = PROJECT_ROOT / "automation" / "data" / "releases"
|
||||
if not releases_dir.exists():
|
||||
return {"packages": []}
|
||||
|
||||
packages = []
|
||||
dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True)
|
||||
for dt in dates:
|
||||
date_dir = releases_dir / dt
|
||||
for platform in ["zhihu", "wechat", "xiaohongshu"]:
|
||||
file_path = date_dir / platform / f"{platform}_{topic_id}_{platform}.html"
|
||||
if file_path.exists():
|
||||
stat = file_path.stat()
|
||||
packages.append({
|
||||
"platform": platform,
|
||||
"path": str(file_path.relative_to(PROJECT_ROOT)),
|
||||
"size": stat.st_size,
|
||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
||||
})
|
||||
|
||||
return {"packages": packages}
|
||||
|
||||
|
||||
@router.delete("/{topic_id}")
|
||||
def delete_topic(topic_id: str, db: Session = Depends(get_db)):
|
||||
"""删除选题"""
|
||||
@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="选题不存在")
|
||||
|
||||
db.delete(topic)
|
||||
raise HTTPException(status_code=404, detail="Topic not found")
|
||||
topic.lock_by = current_user.username
|
||||
topic.lock_at = datetime.now()
|
||||
db.commit()
|
||||
return {"message": "删除成功", "topic_id": topic_id}
|
||||
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")
|
||||
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)
|
||||
):
|
||||
updated = db.query(Topic).filter(Topic.id.in_(topic_ids)).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)
|
||||
):
|
||||
rows = db.query(
|
||||
Topic.field_name,
|
||||
func.count(Topic.id).label("count")
|
||||
).group_by(Topic.field_name).all()
|
||||
return [{"field": r.field_name or "未分类", "count": r.count} for r in rows]
|
||||
Reference in New Issue
Block a user