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:
lt
2026-05-08 18:26:01 +08:00
parent ac48304532
commit cf5103bbca
14 changed files with 2701 additions and 344 deletions
+254 -138
View File
@@ -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]