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:
+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