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
+321
View File
@@ -0,0 +1,321 @@
# 宇之然内容平台 - 项目升级规划
> 从"可持续生活垂直工具" → "通用内容运营全流程平台"
> Phase 1: 自用验证 → Phase 2: SaaS 产品化
> **最后更新: 2026-05-08**
---
## 一、项目定位
**目标**: 构建覆盖"选题→创作→审核→发布→搜集→反馈"全链路的内容运营工具。
**当前阶段**: 自用验证(Own Use),同时为 SaaS 产品化预留架构。
**核心原则**:
- 所有业务逻辑不写死领域/行业相关字段
- 选题系统完全配置化(评分字段/状态/标签/分类均可自定义)
- 数据库迁移到 PostgreSQL,支持后续多租户扩展
---
## 二、当前进度
### ✅ 已完成
| 模块 | 状态 | 说明 |
|------|------|------|
| **数据库迁移** | ✅ 完成 | SQLite → PostgreSQL (yzr_nr),所有表已创建/同步 |
| **选题配置化** | ✅ 完成 | TopicField + TopicConfigField + TopicStatusConfig 模型,灵活领域/评分字段 |
| **选题 CRUD** | ✅ 完成 | 支持 field_id/status/tags 过滤,批量操作,评分计算 |
| **内容日历 API** | ✅ 完成 | 创建/更新/删除排期,按年月查询,状态管理 |
| **数据追踪 API** | ✅ 完成 | ContentMetrics 模型,仪表盘/趋势/多平台汇总 |
| **素材库 API** | ✅ 完成 | 上传/管理/标签/搜索,usage_count 统计 |
| **创作任务 API** | ✅ 完成 | ContentTask 异步任务,状态/进度/结果管理 |
| **平台配置 API** | ✅ 完成 | 知乎/微信/小红书 平台配置,含合规规则 |
| **初始化数据** | ✅ 完成 | 默认领域(10个)、状态(5种)、平台(3个)、管理员 |
| **服务器运行** | ✅ 运行中 | http://localhost:8001 |
### ⚠️ 待前端适配
以下新 API 已完成后端,但前端页面尚未适配:
| 模块 | API | 前端文件 |
|------|-----|---------|
| 选题配置 | /api/topic-config/* | topics.html 需改版 |
| 内容日历 | /api/calendar/* | 需新建 calendar.html |
| 数据分析 | /api/metrics/* | 需新建 metrics.html |
| 素材库 | /api/assets/* | 需新建 assets.html |
| 创作任务 | /api/tasks/* | 需新建 tasks.html |
| 平台配置 | /api/platform-config/* | 需新建 platforms.html |
### ⏳ 未实现功能
| 优先级 | 模块 | 功能点 | 说明 |
|--------|------|--------|------|
| P0 | **内容日历前端** | 日历视图、拖拽排期、提醒设置 | API 已就绪,需前端 |
| P0 | **数据看板前端** | 趋势图、平台对比、选题推荐 | API 已就绪,需前端 |
| P0 | **素材库前端** | 图片上传/管理/预览 | API 已就绪,需前端 |
| P0 | **创作任务前端** | 实时进度条、任务列表、取消 | API 已就绪,需前端 |
| P1 | **多平台适配** | 一篇长文 → 知乎/小红书/微信各格式 | 核心差异化功能 |
| P1 | **热点采集** | 跨平台热搜、趋势追踪 | 有 collector 脚本待集成 |
| P1 | **选题推荐** | 基于历史数据推荐选题 | API 已就绪 (recommend-topics) |
| P2 | **内容复用** | 长文 → 短笔记 → 回答 → 朋友圈文案 | 自动化脚本 |
| P2 | **通知系统** | 企业微信/飞书推送 | 有 wecom_notifier.py 待集成 |
| P2 | **定时任务** | APScheduler 自动执行流水线 | core/scheduler.py 已有 |
| P3 | **多租户** | 租户隔离、团队协作 | SaaS 预留 |
| P3 | **支付订阅** | 免费/专业版分级、用量统计 | SaaS 预留 |
---
## 三、技术架构
### 3.1 技术栈
| 层次 | 技术 | 说明 |
|------|------|------|
| 后端 | FastAPI 0.104+ | ASGI 异步框架 |
| ORM | SQLAlchemy 2.0+ | PostgreSQL + psycopg2 |
| 前端 | Vue 3 (CDN) + Element Plus | SPA 单页应用 |
| 数据库 | PostgreSQL 15 | 自用阶段单租户,SaaS 预留多租户 |
| 认证 | JWT (python-jose) + bcrypt | Bearer Token |
| 定时 | APScheduler | 内容日历/定时任务 |
### 3.2 数据库连接
```
环境变量:
USE_POSTGRES=true
PG_HOST=127.0.0.1
PG_PORT=5432
PG_DATABASE=yzr_nr
PG_USER=yzr_nr
PG_PASSWORD=aTX3WKKnPfRnM5PC
```
### 3.3 项目结构(后端)
```
platform/backend/app/
├── api/ # REST API 路由
│ ├── auth.py # 认证
│ ├── topics.py # 选题 CRUD [重构]
│ ├── articles.py # 文章管理
│ ├── publishing.py # 发布记录
│ ├── calendar.py # 内容日历 [NEW]
│ ├── metrics.py # 数据分析 [NEW]
│ ├── assets.py # 素材库 [NEW]
│ ├── tasks.py # 创作任务 [NEW]
│ ├── platform_config.py # 平台配置 [NEW]
│ ├── topic_config.py # 选题配置 [NEW]
│ └── admin/ # 管理后台
├── core/ # 业务逻辑
├── models.py # SQLAlchemy 模型 [重构]
├── schemas.py # Pydantic 验证 [重构]
├── database.py # 数据库连接 [重构]
├── initial_data.py # 初始化数据 [重构]
└── main.py # FastAPI 入口 [更新]
platform/frontend/
├── index.html # 主仪表盘
├── topics.html # 选题管理
├── calendar.html # 内容日历 [待建]
├── metrics.html # 数据分析 [待建]
├── assets.html # 素材库 [待建]
├── login.html
├── admin.html
└── ...
```
---
## 四、数据模型
### 4.1 选题系统(配置化)
```
TopicField(领域/分类)
id, name, icon, color, description, parent_id, sort_order, is_active
TopicConfigField(选题评分字段配置)
id, field_id → TopicField, name, key, field_type, weight, options, min/max, is_required, sort_order
TopicStatusConfig(状态配置)
id, status, label, color, icon, sort_order, is_default
Topic(选题,主表)
id, field_id → TopicField, field_name, title, status, priority_score, total_score,
format, core_concept, audience_pain, unique_angle, priority,
tags(JSON), custom_data(JSON), scoring_data(JSON),
cases(JSON), source_file, lock_by, lock_at,
created_at, updated_at, generated_at, ready_at, published_at,
compliance_score, platform_urls(JSON)
```
### 4.2 新增模型
| 模型 | 用途 |
|------|------|
| ContentCalendar | 内容日历/排期管理 |
| ContentMetrics | 文章数据追踪(阅读/点赞/评论等) |
| MediaAsset | 素材库(图片/视频/文档) |
| PlatformConfig | 平台配置(知乎/微信/小红书格式规则) |
| ContentTask | 创作任务(异步流水线状态) |
---
## 五、API 清单
### 选题配置
```
GET /api/topic-config/fields
POST /api/topic-config/fields
PUT /api/topic-config/fields/{field_id}
DELETE /api/topic-config/fields/{field_id}
GET /api/topic-config/fields/{field_id}/scoring
POST /api/topic-config/fields/{field_id}/scoring
PUT /api/topic-config/scoring/{config_id}
DELETE /api/topic-config/scoring/{config_id}
POST /api/topic-config/fields/{field_id}/scoring/batch
```
### 选题
```
GET /api/topics?field_id=&status=&tag=&search=&limit=&offset=
POST /api/topics
GET /api/topics/stats
GET /api/topics/{topic_id}
PUT /api/topics/{topic_id}
DELETE /api/topics/{topic_id}
POST /api/topics/{topic_id}/score
POST /api/topics/{topic_id}/publish
POST /api/topics/{topic_id}/lock
POST /api/topics/{topic_id}/unlock
GET /api/topics/{topic_id}/articles
GET /api/topics/{topic_id}/metrics
POST /api/topics/batch-update-status
GET /api/topics/field-distribution
```
### 内容日历
```
GET /api/calendar?year=&month=
GET /api/calendar/entries?start_date=&end_date=&status=&platform=
POST /api/calendar/entries
PUT /api/calendar/entries/{entry_id}
DELETE /api/calendar/entries/{entry_id}
POST /api/calendar/entries/bind-topic
POST /api/calendar/entries/from-topic?topic_id=&platform=
GET /api/calendar/stats?year=&month=
```
### 数据分析
```
GET /api/metrics/dashboard?days=
GET /api/metrics/trend?days=&group_by=
GET /api/metrics/entries?topic_id=&platform=
POST /api/metrics/entries
PUT /api/metrics/entries/{metric_id}
DELETE /api/metrics/entries/{metric_id}
GET /api/metrics/topics/{topic_id}
GET /api/metrics/by-platform
GET /api/metrics/recommend-topics?limit=
```
### 素材库
```
GET /api/assets?file_type=&tag=&topic_id=&search=&limit=&offset=
GET /api/assets/tags
GET /api/assets/counts
POST /api/assets/upload
PUT /api/assets/{asset_id}
DELETE /api/assets/{asset_id}
POST /api/assets/{asset_id}/use
```
### 创作任务
```
GET /api/tasks?status=&topic_id=&stage=&limit=
GET /api/tasks/active
POST /api/tasks
GET /api/tasks/{task_id}
PUT /api/tasks/{task_id}/start
PUT /api/tasks/{task_id}/progress
PUT /api/tasks/{task_id}/complete
PUT /api/tasks/{task_id}/fail
DELETE /api/tasks/{task_id}
POST /api/tasks/run-creator
```
### 平台配置
```
GET /api/platform-config?active_only=
POST /api/platform-config
GET /api/platform-config/{platform}
PUT /api/platform-config/{platform}
DELETE /api/platform-config/{platform}
```
---
## 六、实施计划
### Phase 1(当前):自用验证
**目标**: 2个月内完成全链路闭环,自己稳定产出10-20篇文章
**已完成**:
- ✅ 数据库迁移 PostgreSQL
- ✅ 选题系统配置化
- ✅ 所有新模型 + API 后端完成
- ✅ 服务运行中
**进行中**:
- ⏳ 前端适配(新页面)
**待开发**:
- 前端: 内容日历/数据分析/素材库/创作任务 页面
- 前端: 选题管理改版(适配配置化)
- 集成: 热点采集脚本 → API
- 集成: 企微通知 → API
- 集成: 多平台格式适配
### Phase 2SaaS 产品化(待定)
---
## 七、启动方式
```bash
cd /root/openclaw-workspace/projects/yu-zhi-ran/platform/backend
# 环境变量(.env 已配置)
USE_POSTGRES=true PG_HOST=127.0.0.1 PG_PORT=5432 \
PG_DATABASE=yzr_nr PG_USER=yzr_nr PG_PASSWORD=aTX3WKKnPfRnM5PC \
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8001 --reload
# 默认管理员: admin / admin123
# API 文档: http://localhost:8001/docs
```
---
## 八、数据库当前状态
```
领域: 10 个(未来工作方式、AI与效率、可持续生活、数字游民、个人成长、科技人文、个人知识工厂、科技人文交叉、可持续生活系统、测试)
选题: 26 个
状态分布: 待处理20 / 待审查2 / 待发布3 / 已发布1
平台: 3 个(知乎/微信公众号/小红书)
```
---
*文档版本: v0.2*
*更新时间: 2026-05-08*
+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
+252 -136
View File
@@ -1,189 +1,305 @@
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from typing import List, Optional from sqlalchemy import func
from typing import List, Optional, Dict, Any
from datetime import datetime, date from datetime import datetime, date
from pathlib import Path from pathlib import Path
from ..database import get_db from ..database import get_db
from ..models import Topic, PublishRecord from ..models import Topic, TopicField, TopicConfigField, Article, PublishRecord, ContentMetrics
from ..schemas import TopicResponse, PublishRequest, PublishActionRequest, PublishRecordResponse from ..schemas import (
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
PublishRequest, PublishActionRequest, PublishRecordResponse
)
from .auth import get_current_user from .auth import get_current_user
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)]) router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
PROJECT_ROOT = Path(__file__).parent.parent.parent PROJECT_ROOT = Path(__file__).parent.parent.parent
@router.get("", response_model=List[TopicResponse]) @router.get("", response_model=List[TopicResponse])
def list_topics( 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: Session = Depends(get_db)
): ):
# 确保读取最新数据,清除会话缓存
db.expire_all() 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: if status:
query = query.filter(Topic.status == status) query = query.filter(Topic.status == status)
topics = query.order_by(Topic.priority_score.desc(), Topic.created_at.desc()).all() if tag:
return topics 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) @router.get("/{topic_id}", response_model=TopicResponse)
def get_topic(topic_id: str, db: Session = Depends(get_db)): 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: if not topic:
raise HTTPException(status_code=404, detail="Topic not found") raise HTTPException(status_code=404, detail="Topic not found")
return topic 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") @router.post("/{topic_id}/publish")
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)): def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
topic = db.query(Topic).filter(Topic.id == topic_id).first() topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic: if not topic:
raise HTTPException(status_code=404, detail="Topic not found") raise HTTPException(status_code=404, detail="Topic not found")
if topic.status != "ready": if topic.status not in ("pending", "ready", "draft"):
raise HTTPException(status_code=400, detail="Topic not in ready status") raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布")
# 更新选题状态
topic.status = "published" topic.status = "published"
topic.published_at = datetime.now().date() topic.published_at = date.today()
topic.updated_at = datetime.now() topic.updated_at = datetime.now()
topic.platform_urls = req.platform_urls topic.platform_urls = req.platform_urls
db.commit()
# 创建发布记录
record = PublishRecord( record = PublishRecord(
topic_id=topic_id, topic_id=topic_id,
platform=req.platform, platform="all",
action="publish", action="publish",
status="success", status="success",
operator=req.operator, description=f"选题 {topic_id} 已发布"
description=req.description,
suggestion=req.suggestion,
url=req.url,
error_msg=req.error_msg
) )
db.add(record) db.add(record)
db.commit() db.commit()
db.refresh(record) return {"ok": True, "topic_id": topic_id}
return {"message": "Topic marked as published", "topic_id": topic_id, "record_id": record.id}
# 获取选题的发布记录 @router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]])
@router.get("/{topic_id}/publish-records", response_model=List[PublishRecordResponse]) def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
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)):
# 验证选题存在
topic = db.query(Topic).filter(Topic.id == topic_id).first() topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic: if not topic:
raise HTTPException(status_code=404, detail="Topic not found") raise HTTPException(status_code=404, detail="Topic not found")
articles = db.query(Article).filter(Article.topic_id == topic_id).all()
record = PublishRecord( return [a.to_dict() if hasattr(a, 'to_dict') else {
topic_id=topic_id, "id": a.id, "topic_id": a.topic_id, "platform": a.platform,
platform=req.platform or "unknown", "status": a.status, "file_path": a.file_path
action=req.action, } for a in articles]
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
# 更新发布记录 @router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]])
@router.put("/publish-records/{record_id}") def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)):
def update_publish_record(record_id: int, req: PublishActionRequest, db: Session = Depends(get_db)): metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all()
record = db.query(PublishRecord).filter(PublishRecord.id == record_id).first() return [m.to_dict() for m in metrics]
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}/preview") @router.post("/{topic_id}/lock")
def preview_topic(topic_id: str, platform: str = Query("zhihu", regex="^(zhihu|wechat|xiaohongshu)$")): def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
"""
预览某选题在指定平台的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)):
"""删除选题"""
topic = db.query(Topic).filter(Topic.id == topic_id).first() topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic: if not topic:
raise HTTPException(status_code=404, detail="选题不存在") raise HTTPException(status_code=404, detail="Topic not found")
topic.lock_by = current_user.username
db.delete(topic) topic.lock_at = datetime.now()
db.commit() 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 import os
from pathlib import Path from pathlib import Path
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
# 数据库配置:通过环境变量控制 USE_POSTGRES = os.getenv('USE_POSTGRES', 'true').lower() == 'true'
USE_POSTGRES = os.getenv('USE_POSTGRES', 'false').lower() == 'true'
if USE_POSTGRES: if USE_POSTGRES:
# PostgreSQL 生产数据库
POSTGRES_CONFIG = { POSTGRES_CONFIG = {
'host': os.getenv('PG_HOST', '127.0.0.1'), 'host': os.getenv('PG_HOST', '127.0.0.1'),
'port': os.getenv('PG_PORT', '5432'), 'port': os.getenv('PG_PORT', '5432'),
@@ -19,10 +16,17 @@ if USE_POSTGRES:
'user': os.getenv('PG_USER', 'yzr_nr'), 'user': os.getenv('PG_USER', 'yzr_nr'),
'password': os.getenv('PG_PASSWORD', 'aTX3WKKnPfRnM5PC') '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']}" SQLALCHEMY_DATABASE_URL = (
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) 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: else:
# SQLite 开发数据库
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data')) DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
os.makedirs(DATA_DIR, exist_ok=True) os.makedirs(DATA_DIR, exist_ok=True)
DB_PATH = os.path.join(DATA_DIR, 'yzr.db') DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
+149 -83
View File
@@ -3,85 +3,36 @@ import os
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from .database import SessionLocal, init_db 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 import bcrypt
# 计算项目根目录(backend/app/initial_data.py -> 上升3层到 yu-zhi-ran
PROJECT_ROOT = Path(__file__).resolve().parents[1] PROJECT_ROOT = Path(__file__).resolve().parents[1]
if os.getenv('PROJECT_ROOT'): if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT')) PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json" TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
CASES_FILE = PROJECT_ROOT / "automation" / "data" / "initial_cases.json" CASES_FILE = PROJECT_ROOT / "automation" / "data" / "initial_cases.json"
# 从环境变量读取管理员配置
DEFAULT_ADMIN_USERNAME = os.getenv('DEFAULT_ADMIN_USERNAME', 'admin') DEFAULT_ADMIN_USERNAME = os.getenv('DEFAULT_ADMIN_USERNAME', 'admin')
DEFAULT_ADMIN_PASSWORD = os.getenv('DEFAULT_ADMIN_PASSWORD', 'admin123') DEFAULT_ADMIN_PASSWORD = os.getenv('DEFAULT_ADMIN_PASSWORD', 'admin123')
def import_initial_data(): def import_initial_data():
db = SessionLocal() db = SessionLocal()
try: try:
# 1. 导入选题数据 if db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first() is None:
if db.query(Topic).count() == 0: hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
if __import__('os').path.exists(TOPICS_FILE): admin = User(
topics = json.loads(open(TOPICS_FILE, encoding='utf-8').read()) username=DEFAULT_ADMIN_USERNAME,
# 去重:保留每个 ID 最后出现的记录 password_hash=hashed.decode('utf-8'),
seen = {} role="admin"
for t in topics: )
seen[t['id']] = t db.add(admin)
unique_topics = list(seen.values()) db.commit()
for t in unique_topics: print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
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("数据库已有选题数据,跳过导入")
# 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: if db.query(LLMConfig).count() == 0:
default_llm = LLMConfig( default_llm = LLMConfig(
name="default_expand", name="default_expand",
@@ -117,7 +68,6 @@ def import_initial_data():
db.commit() db.commit()
print("✅ 插入默认 LLM 配置") print("✅ 插入默认 LLM 配置")
# 5. 插入系统配置默认值
default_system_configs = [ default_system_configs = [
{"key": "collector_enabled", "value": "false", "description": "是否启用采集器"}, {"key": "collector_enabled", "value": "false", "description": "是否启用采集器"},
{"key": "scheduler_interval", "value": "daily", "description": "调度间隔:daily/hourly/weekly"}, {"key": "scheduler_interval", "value": "daily", "description": "调度间隔:daily/hourly/weekly"},
@@ -128,32 +78,148 @@ def import_initial_data():
db.commit() db.commit()
print("✅ 插入默认系统配置") print("✅ 插入默认系统配置")
# 2. 创建默认管理员用户(bcrypt 哈希) if db.query(PlatformConfig).count() == 0:
admin_exists = db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first() platforms = [
if not admin_exists: {
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()) "platform": "zhihu",
admin = User( "name": "知乎",
username=DEFAULT_ADMIN_USERNAME, "icon": "🔍",
password_hash=hashed.decode('utf-8'), "default_format": "长文深度分析,1500-3000字,有数据支撑",
role="admin" "compliance_rules": {
) "max_length": 50000,
db.add(admin) "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() db.commit()
print(f"创建默认管理员: {DEFAULT_ADMIN_USERNAME}") print("插入平台配置")
else:
# 如果管理员已存在但密码为空,更新为默认密码的哈希 if db.query(TopicField).count() == 0:
if not admin_exists.password_hash: fields = [
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()) {"name": "未来工作方式", "icon": "💼", "color": "#667eea", "description": "远程工作、零工经济、职业转型", "sort_order": 1},
admin_exists.password_hash = hashed.decode('utf-8') {"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() db.commit()
print(f"更新管理员密码") print(f"导入 {len(unique_topics)} 个选题")
print(f"管理员已存在: {DEFAULT_ADMIN_USERNAME}") 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: except Exception as e:
import traceback
traceback.print_exc()
print(f"初始化失败: {e}") print(f"初始化失败: {e}")
db.rollback() db.rollback()
finally: finally:
db.close() db.close()
if __name__ == "__main__": if __name__ == "__main__":
init_db() 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 .database import engine, get_db, init_db
from .models import Base 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 from .initial_data import import_initial_data
app = FastAPI(title="宇之然内容创作平台", version="0.1.0") 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(task_logs.router)
app.include_router(llm_configs.router) app.include_router(llm_configs.router)
app.include_router(system_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" FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
+346 -47
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.sql import func
from sqlalchemy.orm import relationship
from .database import Base from .database import Base
from datetime import datetime from datetime import datetime
@@ -8,14 +9,14 @@ class AuditLog(Base):
__tablename__ = "audit_logs" __tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, nullable=True, index=True) # 操作用户ID(未登录/匿名可为空) user_id = Column(Integer, nullable=True, index=True)
username = Column(String, nullable=False) # 操作用户名(冗余存储) username = Column(String, nullable=False)
action = Column(String, nullable=False, index=True) # 操作类型: login/logout/create_user/update_user/delete_user/publish/etc. action = Column(String, nullable=False, index=True)
resource_type = Column(String, nullable=True, index=True) # 资源类型: user/topic/publish_record/etc. resource_type = Column(String, nullable=True, index=True)
resource_id = Column(String, nullable=True) # 资源ID resource_id = Column(String, nullable=True)
details = Column(JSON, default=dict, nullable=True) # 操作详情(变更前后、额外信息等) details = Column(JSON, default=dict, nullable=True)
ip_address = Column(String, nullable=True) # IP 地址 ip_address = Column(String, nullable=True)
user_agent = Column(String, nullable=True) # User-Agent user_agent = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self): def to_dict(self):
@@ -38,8 +39,8 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
username = Column(String, unique=True, nullable=False, index=True) username = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False) # bcrypt 哈希 password_hash = Column(String, nullable=False)
role = Column(String, default="user", nullable=False) # admin/user role = Column(String, default="user", nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=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): class Topic(Base):
__tablename__ = "topics" __tablename__ = "topics"
id = Column(String, primary_key=True, index=True) 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) title = Column(String, nullable=False)
field = Column(String, nullable=False)
format = Column(String) format = Column(String)
core_concept = Column(Text) core_concept = Column(Text)
audience_pain = Column(Text) audience_pain = Column(Text)
unique_angle = Column(Text) unique_angle = Column(Text)
priority = Column(String) # 高/中 priority = Column(String)
priority_score = Column(Integer, default=0) priority_score = Column(Integer, default=0)
total_score = Column(Float) total_score = Column(Float)
status = Column(String, default="pending") # pending/draft/ready/published status = Column(String, default="pending")
cases = Column(JSON, default=list) cases = Column(JSON, default=list)
source_file = Column(String) 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=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) ready_at = Column(Date)
published_at = Column(Date) published_at = Column(Date)
compliance_score = Column(Integer) 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): class Article(Base):
__tablename__ = "articles" __tablename__ = "articles"
id = Column(String, primary_key=True) # e.g., A01_zhihu id = Column(String, primary_key=True)
topic_id = Column(String, nullable=False) topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
platform = Column(String, nullable=False) platform = Column(String, nullable=False)
file_path = 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()) created_at = Column(DateTime(timezone=True), server_default=func.now())
compliance_score = Column(Integer) 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): class PublishRecord(Base):
__tablename__ = "publish_records" __tablename__ = "publish_records"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
topic_id = Column(String, nullable=False) topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
platform = Column(String, nullable=False) # 发布平台:zhihu/wechat/xiaohongshu platform = Column(String, nullable=False)
action = Column(String, nullable=False) # 操作:publish/update/delete/invalid action = Column(String, nullable=False)
status = Column(String, nullable=False) # 状态:success/failed/partial/cancelled status = Column(String, nullable=False)
operator = Column(String, nullable=True) # 操作人 operator = Column(String, nullable=True)
description = Column(Text, nullable=True) # 发布说明 description = Column(Text, nullable=True)
suggestion = Column(Text, nullable=True) # 建议内容 suggestion = Column(Text, nullable=True)
url = Column(String, nullable=True) # 发布后链接 url = Column(String, nullable=True)
error_msg = Column(Text, nullable=True) # 错误信息 error_msg = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关联查询 topic = relationship("Topic")
# 可以添加外键关联到 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): class Case(Base):
@@ -115,13 +417,13 @@ class Case(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
title = Column(String, nullable=False) title = Column(String, nullable=False)
field = Column(String, nullable=False) # 对应四大支柱或其子领域 field = Column(String, nullable=False)
summary = Column(Text) summary = Column(Text)
key_metrics = Column(Text, nullable=True) # 关键数据 key_metrics = Column(Text, nullable=True)
date = Column(String, nullable=True) # 日期或年份字符串 date = Column(String, nullable=True)
source = Column(String, nullable=True) source = Column(String, nullable=True)
credibility_rating = Column(String, nullable=True) # 如 "⭐⭐⭐" credibility_rating = Column(String, nullable=True)
china_applicability = Column(String, nullable=True) # 如 "⭐⭐⭐⭐" china_applicability = Column(String, nullable=True)
source_url = Column(String, nullable=True) source_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -147,13 +449,13 @@ class TaskLog(Base):
__tablename__ = "task_logs" __tablename__ = "task_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
task_name = Column(String, nullable=False) # collector, creator, research, outline, writer, optimizer task_name = Column(String, nullable=False)
topic_id = Column(String, nullable=True) # 关联选题ID topic_id = Column(String, nullable=True)
status = Column(String, nullable=False) # started, completed, failed status = Column(String, nullable=False)
message = Column(Text, nullable=True) message = Column(Text, nullable=True)
started_at = Column(DateTime(timezone=True), server_default=func.now()) started_at = Column(DateTime(timezone=True), server_default=func.now())
finished_at = Column(DateTime(timezone=True), nullable=True) finished_at = Column(DateTime(timezone=True), nullable=True)
duration = Column(Integer, nullable=True) # 秒数 duration = Column(Integer, nullable=True)
def to_dict(self): def to_dict(self):
return { return {
@@ -172,12 +474,12 @@ class LLMConfig(Base):
__tablename__ = "llm_configs" __tablename__ = "llm_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True) 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) 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) temperature = Column(Float, default=0.7)
max_tokens = Column(Integer, default=2000) max_tokens = Column(Integer, default=2000)
model = Column(String, nullable=True) # 后端模型名 model = Column(String, nullable=True)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=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) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
key = Column(String, unique=True, nullable=False) key = Column(String, unique=True, nullable=False)
value = Column(Text, nullable=True) # 可存储 JSON 字符串 value = Column(Text, nullable=True)
description = Column(String, nullable=True) description = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -217,6 +519,3 @@ class SystemConfig(Base):
"created_at": self.created_at.isoformat() if self.created_at 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, "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 datetime import datetime, date
from typing import Optional, List, Dict, Any 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): class AuditLogBase(BaseModel):
user_id: Optional[int] = None user_id: Optional[int] = None
username: str username: str
@@ -13,85 +342,51 @@ class AuditLogBase(BaseModel):
user_agent: Optional[str] = None user_agent: Optional[str] = None
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)
class AuditLogResponse(AuditLogBase): class AuditLogResponse(AuditLogBase):
id: int id: int
model_config = ConfigDict(from_attributes=True)
class UserBase(BaseModel): class UserBase(BaseModel):
username: str username: str
role: str = "user" role: str = "user"
class UserCreate(UserBase): class UserCreate(UserBase):
password: str password: str
class UserUpdate(BaseModel): class UserUpdate(BaseModel):
username: Optional[str] = None username: Optional[str] = None
password: Optional[str] = None password: Optional[str] = None
role: Optional[str] = None role: Optional[str] = None
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
password: str password: str
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
token: str token: str
role: str role: str
user: UserResponse 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): class PublishRequest(BaseModel):
topic_id: str topic_id: str
platform_urls: Dict[str, str] platform_urls: Dict[str, str]
class PublishActionRequest(BaseModel): class PublishActionRequest(BaseModel):
action: str action: str
platform: Optional[str] = None platform: Optional[str] = None
@@ -102,6 +397,7 @@ class PublishActionRequest(BaseModel):
url: Optional[str] = None url: Optional[str] = None
error_msg: Optional[str] = None error_msg: Optional[str] = None
class PublishRecordResponse(BaseModel): class PublishRecordResponse(BaseModel):
id: int id: int
topic_id: str topic_id: str
@@ -117,26 +413,7 @@ class PublishRecordResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) 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): class CaseBase(BaseModel):
title: str title: str
field: str field: str
@@ -148,6 +425,7 @@ class CaseBase(BaseModel):
credibility_rating: Optional[str] = None credibility_rating: Optional[str] = None
china_applicability: Optional[str] = None china_applicability: Optional[str] = None
class CaseResponse(CaseBase): class CaseResponse(CaseBase):
id: int id: int
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
@@ -156,7 +434,6 @@ class CaseResponse(CaseBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# --- 任务日志 Schema ---
class TaskLogBase(BaseModel): class TaskLogBase(BaseModel):
task_name: str task_name: str
topic_id: Optional[str] = None topic_id: Optional[str] = None
@@ -166,13 +443,13 @@ class TaskLogBase(BaseModel):
finished_at: Optional[datetime] = None finished_at: Optional[datetime] = None
duration_seconds: Optional[int] = None duration_seconds: Optional[int] = None
class TaskLogResponse(TaskLogBase): class TaskLogResponse(TaskLogBase):
id: int id: int
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# --- LLM 配置 Schema ---
class LLMConfigBase(BaseModel): class LLMConfigBase(BaseModel):
name: str name: str
system_prompt: Optional[str] = None system_prompt: Optional[str] = None
@@ -182,6 +459,7 @@ class LLMConfigBase(BaseModel):
model: Optional[str] = None model: Optional[str] = None
is_active: bool = True is_active: bool = True
class LLMConfigResponse(LLMConfigBase): class LLMConfigResponse(LLMConfigBase):
id: int id: int
created_at: Optional[datetime] = None created_at: Optional[datetime] = None
@@ -190,14 +468,31 @@ class LLMConfigResponse(LLMConfigBase):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
# --- 系统配置 Schema ---
class SystemConfigBase(BaseModel): class SystemConfigBase(BaseModel):
key: str key: str
value: Optional[str] = None value: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
class SystemConfigResponse(SystemConfigBase): class SystemConfigResponse(SystemConfigBase):
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True) 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
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
import os
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
os.environ['USE_POSTGRES'] = 'true'
os.environ['PG_HOST'] = '127.0.0.1'
os.environ['PG_PORT'] = '5432'
os.environ['PG_DATABASE'] = 'yzr_nr'
os.environ['PG_USER'] = 'yzr_nr'
os.environ['PG_PASSWORD'] = 'aTX3WKKnPfRnM5PC'
from app.database import engine, Base, SessionLocal
from app.models import (
TopicField, TopicConfigField, TopicStatusConfig,
Topic, Article, PublishRecord, User, Case, TaskLog,
LLMConfig, SystemConfig, AuditLog,
ContentCalendar, ContentMetrics, MediaAsset,
PlatformConfig, ContentTask
)
def migrate():
print("Creating new tables in PostgreSQL...")
Base.metadata.create_all(bind=engine)
print("✅ 所有表已创建/同步")
db = SessionLocal()
try:
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("✅ 插入状态配置")
if db.query(PlatformConfig).count() == 0:
platforms = [
{"platform": "zhihu", "name": "知乎", "icon": "🔍",
"default_format": "长文深度分析,1500-3000字,有数据支撑",
"compliance_rules": {"max_length": 50000, "requires_authentication": False}, "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("✅ 插入平台配置")
existing_fields = {f.name: f.id for f in db.query(TopicField).all()}
if db.query(Topic).count() == 0:
import json
PROJECT_ROOT = Path(__file__).parent.parent
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
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
for t in list(seen.values()):
field_id = existing_fields.get(t.get('field'))
topic = Topic(
id=t['id'],
field_id=field_id,
field_name=t.get('field'),
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', []),
ready_at=None,
published_at=None,
compliance_score=t.get('compliance_score'),
platform_urls=t.get('platform_urls', {})
)
db.add(topic)
db.commit()
print(f"✅ 迁移 {len(seen)} 个选题")
else:
print(f"⚠️ 选题文件不存在: {TOPICS_FILE}")
else:
print("✅ 选题已存在,跳过迁移")
import bcrypt
if db.query(User).count() == 0:
hashed = bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt())
admin = User(username="admin", password_hash=hashed.decode('utf-8'), role="admin")
db.add(admin)
db.commit()
print("✅ 创建默认管理员")
print("\n🎉 迁移完成!PostgreSQL 数据库已就绪。")
print("运行: cd platform/backend && python -m uvicorn app.main:app --port 8001 --reload")
except Exception as e:
import traceback
traceback.print_exc()
print(f"迁移失败: {e}")
finally:
db.close()
if __name__ == "__main__":
migrate()