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]
+11 -7
View File
@@ -4,14 +4,11 @@ from sqlalchemy.orm import sessionmaker
import os
from pathlib import Path
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
# 数据库配置:通过环境变量控制
USE_POSTGRES = os.getenv('USE_POSTGRES', 'false').lower() == 'true'
USE_POSTGRES = os.getenv('USE_POSTGRES', 'true').lower() == 'true'
if USE_POSTGRES:
# PostgreSQL 生产数据库
POSTGRES_CONFIG = {
'host': os.getenv('PG_HOST', '127.0.0.1'),
'port': os.getenv('PG_PORT', '5432'),
@@ -19,10 +16,17 @@ if USE_POSTGRES:
'user': os.getenv('PG_USER', 'yzr_nr'),
'password': os.getenv('PG_PASSWORD', 'aTX3WKKnPfRnM5PC')
}
SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_CONFIG['user']}:{POSTGRES_CONFIG['password']}@{POSTGRES_CONFIG['host']}:{POSTGRES_CONFIG['port']}/{POSTGRES_CONFIG['database']}"
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
SQLALCHEMY_DATABASE_URL = (
f"postgresql://{POSTGRES_CONFIG['user']}:{POSTGRES_CONFIG['password']}"
f"@{POSTGRES_CONFIG['host']}:{POSTGRES_CONFIG['port']}/{POSTGRES_CONFIG['database']}"
)
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
pool_pre_ping=True,
pool_size=10,
max_overflow=20
)
else:
# SQLite 开发数据库
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
+150 -84
View File
@@ -3,85 +3,36 @@ import os
from datetime import datetime
from pathlib import Path
from .database import SessionLocal, init_db
from .models import Topic, User, Case, LLMConfig, SystemConfig
from .models import (
Topic, TopicField, TopicConfigField, TopicStatusConfig,
User, Case, LLMConfig, SystemConfig, PlatformConfig
)
import bcrypt
# 计算项目根目录(backend/app/initial_data.py -> 上升3层到 yu-zhi-ran
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
CASES_FILE = PROJECT_ROOT / "automation" / "data" / "initial_cases.json"
# 从环境变量读取管理员配置
DEFAULT_ADMIN_USERNAME = os.getenv('DEFAULT_ADMIN_USERNAME', 'admin')
DEFAULT_ADMIN_PASSWORD = os.getenv('DEFAULT_ADMIN_PASSWORD', 'admin123')
def import_initial_data():
db = SessionLocal()
try:
# 1. 导入选题数据
if db.query(Topic).count() == 0:
if __import__('os').path.exists(TOPICS_FILE):
topics = json.loads(open(TOPICS_FILE, encoding='utf-8').read())
# 去重:保留每个 ID 最后出现的记录
seen = {}
for t in topics:
seen[t['id']] = t
unique_topics = list(seen.values())
for t in unique_topics:
topic = Topic(
id=t['id'],
title=t['title'],
field=t['field'],
format=t.get('format'),
core_concept=t.get('core_concept'),
audience_pain=t.get('audience_pain'),
unique_angle=t.get('unique_angle'),
priority=t.get('priority'),
priority_score=t.get('priority_score', 0),
total_score=t.get('total_score'),
status=t.get('status', 'pending'),
cases=t.get('cases', []),
source_file=t.get('source_file'),
ready_at=datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else None,
published_at=datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else None,
compliance_score=t.get('compliance_score'),
platform_urls=t.get('platform_urls', {})
)
db.add(topic)
db.commit()
print(f"✅ 导入 {len(unique_topics)} 个选题到数据库(去重后)")
else:
print(f"⚠️ 选题文件不存在: {TOPICS_FILE}")
else:
print("数据库已有选题数据,跳过导入")
if db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first() is None:
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
admin = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=hashed.decode('utf-8'),
role="admin"
)
db.add(admin)
db.commit()
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
# 3. 导入案例数据(如果为空)
if db.query(Case).count() == 0:
if __import__('os').path.exists(CASES_FILE):
cases_data = json.loads(open(CASES_FILE, encoding='utf-8').read())
for c in cases_data:
case = Case(
id=c['id'],
title=c['title'],
field=c['field'],
summary=c['summary'],
key_metrics=c.get('key_metrics'),
date=c.get('date'),
source=c['source'],
source_url=c.get('source_url'),
credibility_rating=c.get('credibility_rating'),
china_applicability=c.get('china_applicability')
)
db.add(case)
db.commit()
print(f"✅ 导入 {len(cases_data)} 条案例")
else:
print(f"⚠️ 案例文件不存在: {CASES_FILE}")
# 4. 插入 LLM 默认配置
if db.query(LLMConfig).count() == 0:
default_llm = LLMConfig(
name="default_expand",
@@ -117,7 +68,6 @@ def import_initial_data():
db.commit()
print("✅ 插入默认 LLM 配置")
# 5. 插入系统配置默认值
default_system_configs = [
{"key": "collector_enabled", "value": "false", "description": "是否启用采集器"},
{"key": "scheduler_interval", "value": "daily", "description": "调度间隔:daily/hourly/weekly"},
@@ -128,32 +78,148 @@ def import_initial_data():
db.commit()
print("✅ 插入默认系统配置")
# 2. 创建默认管理员用户(bcrypt 哈希)
admin_exists = db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first()
if not admin_exists:
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
admin = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=hashed.decode('utf-8'),
role="admin"
)
db.add(admin)
if db.query(PlatformConfig).count() == 0:
platforms = [
{
"platform": "zhihu",
"name": "知乎",
"icon": "🔍",
"default_format": "长文深度分析,1500-3000字,有数据支撑",
"compliance_rules": {
"max_length": 50000,
"requires_authentication": False,
"sensitive_words": ["敏感词示例1", "敏感词示例2"]
},
"is_active": True
},
{
"platform": "wechat",
"name": "微信公众号",
"icon": "💚",
"default_format": "公众号图文,800-1500字,亲切口语化",
"compliance_rules": {
"max_length": 20000,
"requires_authentication": True
},
"is_active": True
},
{
"platform": "xiaohongshu",
"name": "小红书",
"icon": "📕",
"default_format": "图文笔记,300-800字,emoji+标签",
"compliance_rules": {
"max_length": 1000,
"requires_tags": True,
"max_tags": 10
},
"is_active": True
}
]
for p in platforms:
db.add(PlatformConfig(**p))
db.commit()
print(f"创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
else:
# 如果管理员已存在但密码为空,更新为默认密码的哈希
if not admin_exists.password_hash:
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
admin_exists.password_hash = hashed.decode('utf-8')
print("插入平台配置")
if db.query(TopicField).count() == 0:
fields = [
{"name": "未来工作方式", "icon": "💼", "color": "#667eea", "description": "远程工作、零工经济、职业转型", "sort_order": 1},
{"name": "AI与效率", "icon": "🤖", "color": "#764ba2", "description": "AI工具、数字助手、效率方法", "sort_order": 2},
{"name": "可持续生活", "icon": "🌿", "color": "#67c23a", "description": "环保、低碳、自然生活方式", "sort_order": 3},
{"name": "数字游民", "icon": "🌍", "color": "#409eff", "description": "旅行、地理自由、海外生活", "sort_order": 4},
{"name": "个人成长", "icon": "📚", "color": "#e6a23c", "description": "学习、技能、认知升级", "sort_order": 5},
{"name": "科技人文", "icon": "🔬", "color": "#f56c6c", "description": "科技伦理、数字生活反思", "sort_order": 6},
]
for f in fields:
db.add(TopicField(**f))
db.commit()
print("✅ 插入默认领域配置")
if db.query(TopicStatusConfig).count() == 0:
statuses = [
{"status": "pending", "label": "待处理", "color": "#E6A23C", "icon": "", "sort_order": 1, "is_default": True},
{"status": "review", "label": "待审查", "color": "#F56C6C", "icon": "🔍", "sort_order": 2},
{"status": "draft", "label": "草稿", "color": "#909399", "icon": "📝", "sort_order": 3},
{"status": "ready", "label": "待发布", "color": "#67C23A", "icon": "", "sort_order": 4},
{"status": "published", "label": "已发布", "color": "#409EFF", "icon": "🚀", "sort_order": 5},
]
for s in statuses:
db.add(TopicStatusConfig(**s))
db.commit()
print("✅ 插入状态配置")
field_map = {}
for f in db.query(TopicField).all():
field_map[f.name] = f.id
if db.query(Topic).count() == 0:
if os.path.exists(TOPICS_FILE):
topics = json.loads(open(TOPICS_FILE, encoding='utf-8').read())
seen = {}
for t in topics:
seen[t['id']] = t
unique_topics = list(seen.values())
for t in unique_topics:
field_id = field_map.get(t.get('field'))
field_name = t.get('field')
topic = Topic(
id=t['id'],
field_id=field_id,
field_name=field_name,
title=t['title'],
format=t.get('format'),
core_concept=t.get('core_concept'),
audience_pain=t.get('audience_pain'),
unique_angle=t.get('unique_angle'),
priority=t.get('priority'),
priority_score=t.get('priority_score', 0),
total_score=t.get('total_score'),
status=t.get('status', 'pending'),
cases=t.get('cases', []),
tags=t.get('tags', []),
source_file=t.get('source_file'),
ready_at=datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else None,
published_at=datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else None,
compliance_score=t.get('compliance_score'),
platform_urls=t.get('platform_urls', {})
)
db.add(topic)
db.commit()
print(f"更新管理员密码")
print(f"管理员已存在: {DEFAULT_ADMIN_USERNAME}")
print(f"导入 {len(unique_topics)} 个选题")
else:
print(f"⚠️ 选题文件不存在: {TOPICS_FILE}")
if db.query(Case).count() == 0:
if os.path.exists(CASES_FILE):
cases_data = json.loads(open(CASES_FILE, encoding='utf-8').read())
for c in cases_data:
case = Case(
id=c['id'],
title=c['title'],
field=c['field'],
summary=c['summary'],
key_metrics=c.get('key_metrics'),
date=c.get('date'),
source=c['source'],
source_url=c.get('source_url'),
credibility_rating=c.get('credibility_rating'),
china_applicability=c.get('china_applicability')
)
db.add(case)
db.commit()
print(f"✅ 导入 {len(cases_data)} 条案例")
print("✅ 初始化完成")
except Exception as e:
import traceback
traceback.print_exc()
print(f"初始化失败: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
init_db()
import_initial_data()
import_initial_data()
+7 -1
View File
@@ -9,7 +9,7 @@ from pathlib import Path
from .database import engine, get_db, init_db
from .models import Base
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config
from .initial_data import import_initial_data
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
@@ -42,6 +42,12 @@ app.include_router(cases.router)
app.include_router(task_logs.router)
app.include_router(llm_configs.router)
app.include_router(system_configs.router)
app.include_router(topic_config.router)
app.include_router(calendar.router)
app.include_router(metrics.router)
app.include_router(assets.router)
app.include_router(tasks.router)
app.include_router(platform_config.router)
# 挂载前端
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
+347 -48
View File
@@ -1,5 +1,6 @@
from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON
from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from .database import Base
from datetime import datetime
@@ -8,14 +9,14 @@ class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, nullable=True, index=True) # 操作用户ID(未登录/匿名可为空)
username = Column(String, nullable=False) # 操作用户名(冗余存储)
action = Column(String, nullable=False, index=True) # 操作类型: login/logout/create_user/update_user/delete_user/publish/etc.
resource_type = Column(String, nullable=True, index=True) # 资源类型: user/topic/publish_record/etc.
resource_id = Column(String, nullable=True) # 资源ID
details = Column(JSON, default=dict, nullable=True) # 操作详情(变更前后、额外信息等)
ip_address = Column(String, nullable=True) # IP 地址
user_agent = Column(String, nullable=True) # User-Agent
user_id = Column(Integer, nullable=True, index=True)
username = Column(String, nullable=False)
action = Column(String, nullable=False, index=True)
resource_type = Column(String, nullable=True, index=True)
resource_id = Column(String, nullable=True)
details = Column(JSON, default=dict, nullable=True)
ip_address = Column(String, nullable=True)
user_agent = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
@@ -38,8 +39,8 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
username = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False) # bcrypt 哈希
role = Column(String, default="user", nullable=False) # admin/user
password_hash = Column(String, nullable=False)
role = Column(String, default="user", nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -52,62 +53,363 @@ class User(Base):
}
class TopicField(Base):
__tablename__ = "topic_fields"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, nullable=False)
icon = Column(String, nullable=True)
color = Column(String, nullable=True)
description = Column(Text, nullable=True)
parent_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
parent = relationship("TopicField", remote_side=[id], backref="children")
scoring_fields = relationship("TopicConfigField", back_populates="field", cascade="all, delete-orphan")
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"icon": self.icon,
"color": self.color,
"description": self.description,
"parent_id": self.parent_id,
"sort_order": self.sort_order,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class TopicConfigField(Base):
__tablename__ = "topic_config_fields"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=False)
name = Column(String, nullable=False)
key = Column(String, nullable=False)
field_type = Column(String, default="number") # number/select/multi_select/text
weight = Column(Float, default=1.0)
options = Column(JSON, default=list) # for select/multi_select
min_value = Column(Float, nullable=True)
max_value = Column(Float, nullable=True)
is_required = Column(Boolean, default=False)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
field = relationship("TopicField", back_populates="scoring_fields")
def to_dict(self):
return {
"id": self.id,
"field_id": self.field_id,
"name": self.name,
"key": self.key,
"field_type": self.field_type,
"weight": self.weight,
"options": self.options or [],
"min_value": self.min_value,
"max_value": self.max_value,
"is_required": self.is_required,
"sort_order": self.sort_order,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class TopicStatusConfig(Base):
__tablename__ = "topic_status_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
status = Column(String, unique=True, nullable=False)
label = Column(String, nullable=False)
color = Column(String, nullable=True)
icon = Column(String, nullable=True)
sort_order = Column(Integer, default=0)
is_default = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id,
"status": self.status,
"label": self.label,
"color": self.color,
"icon": self.icon,
"sort_order": self.sort_order,
"is_default": self.is_default,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class Topic(Base):
__tablename__ = "topics"
id = Column(String, primary_key=True, index=True)
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
field_name = Column(String, nullable=True)
title = Column(String, nullable=False)
field = Column(String, nullable=False)
format = Column(String)
core_concept = Column(Text)
audience_pain = Column(Text)
unique_angle = Column(Text)
priority = Column(String) # 高/中
priority = Column(String)
priority_score = Column(Integer, default=0)
total_score = Column(Float)
status = Column(String, default="pending") # pending/draft/ready/published
status = Column(String, default="pending")
cases = Column(JSON, default=list)
source_file = Column(String)
tags = Column(JSON, default=list)
custom_data = Column(JSON, default=dict)
scoring_data = Column(JSON, default=dict)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
generated_at = Column(DateTime(timezone=True), nullable=True) # 选题创作完成时间
generated_at = Column(DateTime(timezone=True), nullable=True)
ready_at = Column(Date)
published_at = Column(Date)
compliance_score = Column(Integer)
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
platform_urls = Column(JSON, default=dict)
lock_by = Column(String, nullable=True)
lock_at = Column(DateTime, nullable=True)
field = relationship("TopicField", backref="topics")
@property
def field_info(self):
if self.field:
return self.field.to_dict()
return None
class Article(Base):
__tablename__ = "articles"
id = Column(String, primary_key=True) # e.g., A01_zhihu
topic_id = Column(String, nullable=False)
id = Column(String, primary_key=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
platform = Column(String, nullable=False)
file_path = Column(String, nullable=False)
status = Column(String, default="draft") # draft/optimized/published
status = Column(String, default="draft")
created_at = Column(DateTime(timezone=True), server_default=func.now())
compliance_score = Column(Integer)
html_content = Column(Text) # 可缓存HTML内容以便预览
html_content = Column(Text)
word_count = Column(Integer, nullable=True)
outline = Column(Text, nullable=True)
class PublishRecord(Base):
__tablename__ = "publish_records"
id = Column(Integer, primary_key=True, index=True)
topic_id = Column(String, nullable=False)
platform = Column(String, nullable=False) # 发布平台:zhihu/wechat/xiaohongshu
action = Column(String, nullable=False) # 操作:publish/update/delete/invalid
status = Column(String, nullable=False) # 状态:success/failed/partial/cancelled
operator = Column(String, nullable=True) # 操作人
description = Column(Text, nullable=True) # 发布说明
suggestion = Column(Text, nullable=True) # 建议内容
url = Column(String, nullable=True) # 发布后链接
error_msg = Column(Text, nullable=True) # 错误信息
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
platform = Column(String, nullable=False)
action = Column(String, nullable=False)
status = Column(String, nullable=False)
operator = Column(String, nullable=True)
description = Column(Text, nullable=True)
suggestion = Column(Text, nullable=True)
url = Column(String, nullable=True)
error_msg = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关联查询
# 可以添加外键关联到 Topic, 但这里保持简单
topic = relationship("Topic")
class ContentCalendar(Base):
__tablename__ = "content_calendar"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
title = Column(String, nullable=False)
planned_date = Column(Date, nullable=False, index=True)
published_date = Column(Date, nullable=True)
platform = Column(String, nullable=True)
status = Column(String, default="planned") # planned/published/delayed/cancelled
reminder_time = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
topic = relationship("Topic")
def to_dict(self):
return {
"id": self.id,
"topic_id": self.topic_id,
"field_id": self.field_id,
"title": self.title,
"planned_date": self.planned_date.isoformat() if self.planned_date else None,
"published_date": self.published_date.isoformat() if self.published_date else None,
"platform": self.platform,
"status": self.status,
"reminder_time": self.reminder_time.isoformat() if self.reminder_time else None,
"notes": self.notes,
"created_by": self.created_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class ContentMetrics(Base):
__tablename__ = "content_metrics"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=False, index=True)
platform = Column(String, nullable=False)
publish_url = Column(String, nullable=True)
views = Column(Integer, default=0)
likes = Column(Integer, default=0)
favorites = Column(Integer, default=0)
comments = Column(Integer, default=0)
shares = Column(Integer, default=0)
last_fetched = Column(DateTime(timezone=True), nullable=True)
data_snapshot = Column(JSON, default=dict)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
topic = relationship("Topic")
@property
def engagement_rate(self):
total = self.views or 0
if total == 0:
return 0
return round((self.likes or 0) / total * 100, 2)
def to_dict(self):
return {
"id": self.id,
"topic_id": self.topic_id,
"platform": self.platform,
"publish_url": self.publish_url,
"views": self.views,
"likes": self.likes,
"favorites": self.favorites,
"comments": self.comments,
"shares": self.shares,
"engagement_rate": self.engagement_rate,
"last_fetched": self.last_fetched.isoformat() if self.last_fetched else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class MediaAsset(Base):
__tablename__ = "media_assets"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
filename = Column(String, nullable=False)
file_path = Column(String, nullable=False)
file_url = Column(String, nullable=True)
file_type = Column(String, nullable=False) # image/video/document
mime_type = Column(String, nullable=True)
size = Column(Integer, nullable=True)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
thumbnail_path = Column(String, nullable=True)
alt_text = Column(String, nullable=True)
tags = Column(JSON, default=list)
topic_ids = Column(JSON, default=list)
usage_count = Column(Integer, default=0)
uploaded_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"filename": self.filename,
"file_path": self.file_path,
"file_url": self.file_url,
"file_type": self.file_type,
"mime_type": self.mime_type,
"size": self.size,
"width": self.width,
"height": self.height,
"thumbnail_path": self.thumbnail_path,
"alt_text": self.alt_text,
"tags": self.tags or [],
"topic_ids": self.topic_ids or [],
"usage_count": self.usage_count,
"uploaded_by": self.uploaded_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class PlatformConfig(Base):
__tablename__ = "platform_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
platform = Column(String, unique=True, nullable=False) # zhihu/wechat/xiaohongshu
name = Column(String, nullable=False)
icon = Column(String, nullable=True)
api_endpoint = Column(String, nullable=True)
auth_config = Column(JSON, default=dict)
format_template = Column(JSON, default=dict)
compliance_rules = Column(JSON, default=dict)
default_format = Column(Text, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"platform": self.platform,
"name": self.name,
"icon": self.icon,
"api_endpoint": self.api_endpoint,
"format_template": self.format_template or {},
"compliance_rules": self.compliance_rules or {},
"default_format": self.default_format,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class ContentTask(Base):
__tablename__ = "content_tasks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=True, index=True)
task_id = Column(String, unique=True, nullable=False)
stage = Column(String, nullable=False) # research/outline/writer/optimizer/format/publish
status = Column(String, default="pending") # pending/running/completed/failed/cancelled
progress = Column(Integer, default=0)
message = Column(Text, nullable=True)
result_data = Column(JSON, default=dict)
error_msg = Column(Text, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
duration = Column(Integer, nullable=True)
created_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
topic = relationship("Topic")
def to_dict(self):
return {
"id": self.id,
"topic_id": self.topic_id,
"task_id": self.task_id,
"stage": self.stage,
"status": self.status,
"progress": self.progress,
"message": self.message,
"error_msg": self.error_msg,
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
"duration": self.duration,
"created_by": self.created_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class Case(Base):
@@ -115,13 +417,13 @@ class Case(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
title = Column(String, nullable=False)
field = Column(String, nullable=False) # 对应四大支柱或其子领域
field = Column(String, nullable=False)
summary = Column(Text)
key_metrics = Column(Text, nullable=True) # 关键数据
date = Column(String, nullable=True) # 日期或年份字符串
key_metrics = Column(Text, nullable=True)
date = Column(String, nullable=True)
source = Column(String, nullable=True)
credibility_rating = Column(String, nullable=True) # 如 "⭐⭐⭐"
china_applicability = Column(String, nullable=True) # 如 "⭐⭐⭐⭐"
credibility_rating = Column(String, nullable=True)
china_applicability = Column(String, nullable=True)
source_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -147,13 +449,13 @@ class TaskLog(Base):
__tablename__ = "task_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
task_name = Column(String, nullable=False) # collector, creator, research, outline, writer, optimizer
topic_id = Column(String, nullable=True) # 关联选题ID
status = Column(String, nullable=False) # started, completed, failed
task_name = Column(String, nullable=False)
topic_id = Column(String, nullable=True)
status = Column(String, nullable=False)
message = Column(Text, nullable=True)
started_at = Column(DateTime(timezone=True), server_default=func.now())
finished_at = Column(DateTime(timezone=True), nullable=True)
duration = Column(Integer, nullable=True) # 秒数
duration = Column(Integer, nullable=True)
def to_dict(self):
return {
@@ -172,12 +474,12 @@ class LLMConfig(Base):
__tablename__ = "llm_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, unique=True, nullable=False) # 如 default_expand
name = Column(String, unique=True, nullable=False)
system_prompt = Column(Text, nullable=True)
user_prompt_template = Column(Text, nullable=False) # 含占位符 {topic.get('title')} 等
user_prompt_template = Column(Text, nullable=False)
temperature = Column(Float, default=0.7)
max_tokens = Column(Integer, default=2000)
model = Column(String, nullable=True) # 后端模型名
model = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -202,7 +504,7 @@ class SystemConfig(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
key = Column(String, unique=True, nullable=False)
value = Column(Text, nullable=True) # 可存储 JSON 字符串
value = Column(Text, nullable=True)
description = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -216,7 +518,4 @@ class SystemConfig(Base):
"description": self.description,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
# --- 新增模型:案例库 ---
}
+361 -66
View File
@@ -1,7 +1,336 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from datetime import datetime, date
from typing import Optional, List, Dict, Any
class TopicFieldBase(BaseModel):
name: str
icon: Optional[str] = None
color: Optional[str] = None
description: Optional[str] = None
parent_id: Optional[int] = None
sort_order: int = 0
is_active: bool = True
class TopicFieldResponse(TopicFieldBase):
id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class TopicConfigFieldBase(BaseModel):
name: str
key: str
field_type: str = "number"
weight: float = 1.0
options: List[Any] = []
min_value: Optional[float] = None
max_value: Optional[float] = None
is_required: bool = False
sort_order: int = 0
class TopicConfigFieldResponse(TopicConfigFieldBase):
id: int
field_id: int
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class TopicStatusConfigBase(BaseModel):
status: str
label: str
color: Optional[str] = None
icon: Optional[str] = None
sort_order: int = 0
is_default: bool = False
class TopicStatusConfigResponse(TopicStatusConfigBase):
id: int
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class TopicBase(BaseModel):
id: str
field_id: Optional[int] = None
field_name: Optional[str] = None
title: str
format: Optional[str] = None
core_concept: Optional[str] = None
audience_pain: Optional[str] = None
unique_angle: Optional[str] = None
priority: Optional[str] = None
priority_score: int = 0
total_score: Optional[float] = None
status: str = "pending"
tags: List[str] = []
custom_data: Dict[str, Any] = {}
scoring_data: Dict[str, Any] = {}
lock_by: Optional[str] = None
lock_at: Optional[datetime] = None
class TopicCreate(BaseModel):
id: Optional[str] = None
field_id: Optional[int] = None
title: str
format: Optional[str] = None
core_concept: Optional[str] = None
audience_pain: Optional[str] = None
unique_angle: Optional[str] = None
priority: Optional[str] = None
tags: List[str] = []
custom_data: Dict[str, Any] = {}
scoring_data: Dict[str, Any] = {}
class TopicUpdate(BaseModel):
field_id: Optional[int] = None
title: Optional[str] = None
format: Optional[str] = None
core_concept: Optional[str] = None
audience_pain: Optional[str] = None
unique_angle: Optional[str] = None
priority: Optional[str] = None
status: Optional[str] = None
tags: Optional[List[str]] = None
custom_data: Optional[Dict[str, Any]] = None
scoring_data: Optional[Dict[str, Any]] = None
priority_score: Optional[int] = None
total_score: Optional[float] = None
class TopicResponse(TopicBase):
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
generated_at: Optional[datetime] = None
ready_at: Optional[date] = None
published_at: Optional[date] = None
compliance_score: Optional[int] = None
platform_urls: Dict[str, str] = {}
cases: List[Any] = []
model_config = ConfigDict(from_attributes=True)
class TopicScoreRequest(BaseModel):
scoring_data: Dict[str, Any]
class ArticleBase(BaseModel):
id: str
topic_id: str
platform: str
file_path: str
status: str = "draft"
compliance_score: Optional[int] = None
word_count: Optional[int] = None
outline: Optional[str] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class ArticleResponse(ArticleBase):
html_content: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class ContentCalendarBase(BaseModel):
topic_id: Optional[str] = None
field_id: Optional[int] = None
title: str
planned_date: date
platform: Optional[str] = None
status: str = "planned"
reminder_time: Optional[datetime] = None
notes: Optional[str] = None
class ContentCalendarCreate(ContentCalendarBase):
created_by: Optional[str] = None
class ContentCalendarUpdate(BaseModel):
topic_id: Optional[str] = None
title: Optional[str] = None
planned_date: Optional[date] = None
published_date: Optional[date] = None
platform: Optional[str] = None
status: Optional[str] = None
reminder_time: Optional[datetime] = None
notes: Optional[str] = None
class ContentCalendarResponse(ContentCalendarBase):
id: int
published_date: Optional[date] = None
created_by: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class ContentMetricsBase(BaseModel):
topic_id: str
platform: str
publish_url: Optional[str] = None
views: int = 0
likes: int = 0
favorites: int = 0
comments: int = 0
shares: int = 0
class ContentMetricsCreate(ContentMetricsBase):
data_snapshot: Optional[Dict[str, Any]] = None
class ContentMetricsUpdate(BaseModel):
publish_url: Optional[str] = None
views: Optional[int] = None
likes: Optional[int] = None
favorites: Optional[int] = None
comments: Optional[int] = None
shares: Optional[int] = None
data_snapshot: Optional[Dict[str, Any]] = None
class ContentMetricsResponse(ContentMetricsBase):
id: int
engagement_rate: float = 0
last_fetched: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class MediaAssetBase(BaseModel):
filename: str
file_path: str
file_url: Optional[str] = None
file_type: str
mime_type: Optional[str] = None
size: Optional[int] = None
width: Optional[int] = None
height: Optional[int] = None
thumbnail_path: Optional[str] = None
alt_text: Optional[str] = None
tags: List[str] = []
topic_ids: List[str] = []
class MediaAssetCreate(MediaAssetBase):
uploaded_by: Optional[str] = None
class MediaAssetUpdate(BaseModel):
alt_text: Optional[str] = None
tags: Optional[List[str]] = None
topic_ids: Optional[List[str]] = None
class MediaAssetResponse(MediaAssetBase):
id: int
usage_count: int = 0
uploaded_by: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class PlatformConfigBase(BaseModel):
platform: str
name: str
icon: Optional[str] = None
api_endpoint: Optional[str] = None
auth_config: Dict[str, Any] = {}
format_template: Dict[str, Any] = {}
compliance_rules: Dict[str, Any] = {}
default_format: Optional[str] = None
is_active: bool = True
class PlatformConfigCreate(PlatformConfigBase):
pass
class PlatformConfigUpdate(BaseModel):
name: Optional[str] = None
icon: Optional[str] = None
api_endpoint: Optional[str] = None
auth_config: Optional[Dict[str, Any]] = None
format_template: Optional[Dict[str, Any]] = None
compliance_rules: Optional[Dict[str, Any]] = None
default_format: Optional[str] = None
is_active: Optional[bool] = None
class PlatformConfigResponse(PlatformConfigBase):
id: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class ContentTaskBase(BaseModel):
topic_id: Optional[str] = None
stage: str
status: str = "pending"
progress: int = 0
message: Optional[str] = None
class ContentTaskCreate(BaseModel):
topic_id: Optional[str] = None
stage: str
created_by: Optional[str] = None
class ContentTaskResponse(ContentTaskBase):
id: int
task_id: str
result_data: Dict[str, Any] = {}
error_msg: Optional[str] = None
started_at: Optional[datetime] = None
finished_at: Optional[datetime] = None
duration: Optional[int] = None
created_by: Optional[str] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class MetricsDashboard(BaseModel):
total_topics: int
topics_by_status: Dict[str, int]
total_published: int
total_views: int
total_likes: int
avg_engagement_rate: float
top_topics: List[Dict[str, Any]]
recent_metrics: List[ContentMetricsResponse]
class CalendarMonthResponse(BaseModel):
year: int
month: int
entries: List[ContentCalendarResponse]
stats: Dict[str, int]
class AuditLogBase(BaseModel):
user_id: Optional[int] = None
username: str
@@ -13,85 +342,51 @@ class AuditLogBase(BaseModel):
user_agent: Optional[str] = None
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class AuditLogResponse(AuditLogBase):
id: int
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel):
username: str
role: str = "user"
class UserCreate(UserBase):
password: str
class UserUpdate(BaseModel):
username: Optional[str] = None
password: Optional[str] = None
role: Optional[str] = None
class UserResponse(UserBase):
id: int
created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
token: str
role: str
user: UserResponse
class TopicBase(BaseModel):
id: str
title: str
field: str
priority_score: int = 0
status: str = "pending"
compliance_score: Optional[int] = None
ready_at: Optional[date] = None
published_at: Optional[date] = None
generated_at: Optional[datetime] = None
platform_urls: Optional[Dict[str, str]] = None
class TopicResponse(TopicBase):
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class ArticleBase(BaseModel):
id: str
topic_id: str
platform: str
file_path: str
status: str = "draft"
compliance_score: Optional[int] = None
created_at: Optional[datetime] = None
class ArticleResponse(ArticleBase):
model_config = ConfigDict(from_attributes=True)
class SystemStatus(BaseModel):
total_topics: int
topics_by_status: Dict[str, int]
ready_topics: List[TopicResponse]
today_articles: int
compliance_rate: float
last_optimization: Optional[datetime] = None
execution_time: Optional[float] = None
class OptimizationRequest(BaseModel):
topic_ids: Optional[List[str]] = None
class PublishRequest(BaseModel):
topic_id: str
platform_urls: Dict[str, str]
class PublishActionRequest(BaseModel):
action: str
platform: Optional[str] = None
@@ -102,6 +397,7 @@ class PublishActionRequest(BaseModel):
url: Optional[str] = None
error_msg: Optional[str] = None
class PublishRecordResponse(BaseModel):
id: int
topic_id: str
@@ -117,26 +413,7 @@ class PublishRecordResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
class BatchPublishRequest(BaseModel):
date: str
class AuditLogResponse(BaseModel):
id: int
user_id: Optional[int] = None
username: str
action: str
resource_type: Optional[str] = None
resource_id: Optional[str] = None
details: Optional[Dict[str, Any]] = None
ip_address: Optional[str] = None
user_agent: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# --- 案例库 Schema ---
class CaseBase(BaseModel):
title: str
field: str
@@ -148,6 +425,7 @@ class CaseBase(BaseModel):
credibility_rating: Optional[str] = None
china_applicability: Optional[str] = None
class CaseResponse(CaseBase):
id: int
created_at: Optional[datetime] = None
@@ -156,7 +434,6 @@ class CaseResponse(CaseBase):
model_config = ConfigDict(from_attributes=True)
# --- 任务日志 Schema ---
class TaskLogBase(BaseModel):
task_name: str
topic_id: Optional[str] = None
@@ -166,13 +443,13 @@ class TaskLogBase(BaseModel):
finished_at: Optional[datetime] = None
duration_seconds: Optional[int] = None
class TaskLogResponse(TaskLogBase):
id: int
model_config = ConfigDict(from_attributes=True)
# --- LLM 配置 Schema ---
class LLMConfigBase(BaseModel):
name: str
system_prompt: Optional[str] = None
@@ -182,6 +459,7 @@ class LLMConfigBase(BaseModel):
model: Optional[str] = None
is_active: bool = True
class LLMConfigResponse(LLMConfigBase):
id: int
created_at: Optional[datetime] = None
@@ -190,14 +468,31 @@ class LLMConfigResponse(LLMConfigBase):
model_config = ConfigDict(from_attributes=True)
# --- 系统配置 Schema ---
class SystemConfigBase(BaseModel):
key: str
value: Optional[str] = None
description: Optional[str] = None
class SystemConfigResponse(SystemConfigBase):
updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class SystemStatus(BaseModel):
total_topics: int
topics_by_status: Dict[str, int]
ready_topics: List[TopicResponse]
today_articles: int
compliance_rate: float
last_optimization: Optional[datetime] = None
execution_time: Optional[float] = None
class OptimizationRequest(BaseModel):
topic_ids: Optional[List[str]] = None
class BatchPublishRequest(BaseModel):
date: str