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
+180
View File
@@ -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}
+188
View File
@@ -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
+269
View File
@@ -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}
+234
View File
@@ -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
+174
View File
@@ -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
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]