diff --git a/PROJECT_UPGRADE.md b/PROJECT_UPGRADE.md new file mode 100644 index 0000000..ee62252 --- /dev/null +++ b/PROJECT_UPGRADE.md @@ -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 2:SaaS 产品化(待定) + +--- + +## 七、启动方式 + +```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* \ No newline at end of file diff --git a/platform/backend/app/api/assets.py b/platform/backend/app/api/assets.py new file mode 100644 index 0000000..39bf322 --- /dev/null +++ b/platform/backend/app/api/assets.py @@ -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} \ No newline at end of file diff --git a/platform/backend/app/api/calendar.py b/platform/backend/app/api/calendar.py new file mode 100644 index 0000000..845bae6 --- /dev/null +++ b/platform/backend/app/api/calendar.py @@ -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 \ No newline at end of file diff --git a/platform/backend/app/api/metrics.py b/platform/backend/app/api/metrics.py new file mode 100644 index 0000000..63b0a29 --- /dev/null +++ b/platform/backend/app/api/metrics.py @@ -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] \ No newline at end of file diff --git a/platform/backend/app/api/platform_config.py b/platform/backend/app/api/platform_config.py new file mode 100644 index 0000000..e70ecad --- /dev/null +++ b/platform/backend/app/api/platform_config.py @@ -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} \ No newline at end of file diff --git a/platform/backend/app/api/tasks.py b/platform/backend/app/api/tasks.py new file mode 100644 index 0000000..7c9bad4 --- /dev/null +++ b/platform/backend/app/api/tasks.py @@ -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 \ No newline at end of file diff --git a/platform/backend/app/api/topic_config.py b/platform/backend/app/api/topic_config.py new file mode 100644 index 0000000..8c35a32 --- /dev/null +++ b/platform/backend/app/api/topic_config.py @@ -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 \ No newline at end of file diff --git a/platform/backend/app/api/topics.py b/platform/backend/app/api/topics.py index e318729..9ce736b 100644 --- a/platform/backend/app/api/topics.py +++ b/platform/backend/app/api/topics.py @@ -1,189 +1,305 @@ -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func +from typing import List, Optional, Dict, Any from datetime import datetime, date from pathlib import Path from ..database import get_db -from ..models import Topic, PublishRecord -from ..schemas import TopicResponse, PublishRequest, PublishActionRequest, PublishRecordResponse +from ..models import Topic, TopicField, TopicConfigField, Article, PublishRecord, ContentMetrics +from ..schemas import ( + TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest, + PublishRequest, PublishActionRequest, PublishRecordResponse +) from .auth import get_current_user router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)]) PROJECT_ROOT = Path(__file__).parent.parent.parent + @router.get("", response_model=List[TopicResponse]) def list_topics( - status: str = None, + field_id: Optional[int] = None, + status: Optional[str] = None, + tag: Optional[str] = None, + search: Optional[str] = None, + sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]), + order: str = Query("desc", enum=["asc", "desc"]), + limit: int = Query(50, le=200), + offset: int = Query(0, ge=0), db: Session = Depends(get_db) ): - # 确保读取最新数据,清除会话缓存 db.expire_all() - query = db.query(Topic) + query = db.query(Topic).options(joinedload(Topic.field)) + + if field_id: + query = query.filter(Topic.field_id == field_id) if status: query = query.filter(Topic.status == status) - topics = query.order_by(Topic.priority_score.desc(), Topic.created_at.desc()).all() - return topics + if tag: + query = query.filter(Topic.tags.contains([tag])) + if search: + query = query.filter(Topic.title.contains(search)) + + sort_col = getattr(Topic, sort_by, Topic.priority_score) + if order == "desc": + query = query.order_by(sort_col.desc()) + else: + query = query.order_by(sort_col.asc()) + + return query.offset(offset).limit(limit).all() + + +@router.get("/stats") +def topic_stats( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + total = db.query(Topic).count() + raw = db.query(Topic.status, func.count()).group_by(Topic.status).all() + by_status = {s: c for s, c in raw} + + today = date.today() + today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count() + + published = db.query(Topic).filter(Topic.status == "published").count() + metrics_count = db.query(ContentMetrics).count() + + return { + "total": total, + "by_status": by_status, + "published": published, + "today_created": today_count, + "metrics_count": metrics_count + } + + +@router.post("", response_model=TopicResponse) +def create_topic( + data: TopicCreate, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + topic_id = data.id + if not topic_id: + max_topic = db.query(Topic).order_by(Topic.id.desc()).first() + if max_topic and max_topic.id.startswith("T"): + try: + num = int(max_topic.id[1:]) + 1 + topic_id = f"T{num:03d}" + except: + topic_id = f"T{datetime.now().strftime('%m%d%H%M')}" + else: + topic_id = f"T{datetime.now().strftime('%m%d%H%M')}" + + field_name = None + if data.field_id: + field = db.query(TopicField).filter(TopicField.id == data.field_id).first() + if field: + field_name = field.name + + topic = Topic( + id=topic_id, + field_id=data.field_id, + field_name=field_name, + title=data.title, + format=data.format, + core_concept=data.core_concept, + audience_pain=data.audience_pain, + unique_angle=data.unique_angle, + priority=data.priority, + status="pending", + tags=data.tags or [], + custom_data=data.custom_data or {}, + scoring_data=data.scoring_data or {}, + ) + db.add(topic) + db.commit() + db.refresh(topic) + return topic + @router.get("/{topic_id}", response_model=TopicResponse) def get_topic(topic_id: str, db: Session = Depends(get_db)): - topic = db.query(Topic).filter(Topic.id == topic_id).first() + topic = db.query(Topic).options(joinedload(Topic.field)).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") return topic + +@router.put("/{topic_id}", response_model=TopicResponse) +def update_topic( + topic_id: str, + data: TopicUpdate, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if not topic: + raise HTTPException(status_code=404, detail="Topic not found") + + if data.field_id is not None: + topic.field_id = data.field_id + if data.field_id: + field = db.query(TopicField).filter(TopicField.id == data.field_id).first() + topic.field_name = field.name if field else None + + for k, v in data.model_dump(exclude_unset=True, exclude={"field_id"}).items(): + if k == "tags" or k == "custom_data" or k == "scoring_data": + if v is not None: + setattr(topic, k, v) + elif v is not None: + setattr(topic, k, v) + + topic.updated_at = datetime.now() + db.commit() + db.refresh(topic) + return topic + + +@router.delete("/{topic_id}") +def delete_topic( + topic_id: str, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if not topic: + raise HTTPException(status_code=404, detail="Topic not found") + db.delete(topic) + db.commit() + return {"ok": True} + + +@router.post("/{topic_id}/score") +def score_topic( + topic_id: str, + data: TopicScoreRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if not topic: + raise HTTPException(status_code=404, detail="Topic not found") + + topic.scoring_data = data.scoring_data + + if data.scoring_data and topic.field_id: + configs = db.query(TopicConfigField).filter( + TopicConfigField.field_id == topic.field_id + ).all() + + total_weight = 0 + weighted_sum = 0 + for cfg in configs: + val = data.scoring_data.get(cfg.key) + if val is not None and cfg.field_type == "number": + if cfg.min_value is not None: + val = max(val, cfg.min_value) + if cfg.max_value is not None: + val = min(val, cfg.max_value) + normalized = (val - cfg.min_value) / (cfg.max_value - cfg.min_value) if cfg.max_value != cfg.min_value else 0.5 + weighted_sum += normalized * cfg.weight + total_weight += cfg.weight + + if total_weight > 0: + topic.total_score = round(weighted_sum / total_weight * 100, 1) + topic.priority_score = int(topic.total_score) + + topic.updated_at = datetime.now() + db.commit() + db.refresh(topic) + return {"priority_score": topic.priority_score, "total_score": topic.total_score} + + @router.post("/{topic_id}/publish") def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)): topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") - if topic.status != "ready": - raise HTTPException(status_code=400, detail="Topic not in ready status") - - # 更新选题状态 + if topic.status not in ("pending", "ready", "draft"): + raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布") + topic.status = "published" - topic.published_at = datetime.now().date() + topic.published_at = date.today() topic.updated_at = datetime.now() topic.platform_urls = req.platform_urls - db.commit() - - # 创建发布记录 + record = PublishRecord( topic_id=topic_id, - platform=req.platform, + platform="all", action="publish", status="success", - operator=req.operator, - description=req.description, - suggestion=req.suggestion, - url=req.url, - error_msg=req.error_msg + description=f"选题 {topic_id} 已发布" ) db.add(record) db.commit() - db.refresh(record) - - return {"message": "Topic marked as published", "topic_id": topic_id, "record_id": record.id} + return {"ok": True, "topic_id": topic_id} -# 获取选题的发布记录 -@router.get("/{topic_id}/publish-records", response_model=List[PublishRecordResponse]) -def get_publish_records(topic_id: str, db: Session = Depends(get_db)): - records = db.query(PublishRecord).filter(PublishRecord.topic_id == topic_id).order_by(PublishRecord.created_at.desc()).all() - return records - - -# 创建新的发布记录(用于手动记录发布情况) -@router.post("/{topic_id}/publish-records") -def create_publish_record(topic_id: str, req: PublishActionRequest, db: Session = Depends(get_db)): - # 验证选题存在 +@router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]]) +def get_topic_articles(topic_id: str, db: Session = Depends(get_db)): topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: raise HTTPException(status_code=404, detail="Topic not found") - - record = PublishRecord( - topic_id=topic_id, - platform=req.platform or "unknown", - action=req.action, - status=req.status, - operator=req.operator, - description=req.description, - suggestion=req.suggestion, - url=req.url, - error_msg=req.error_msg - ) - db.add(record) - db.commit() - db.refresh(record) - - # 如果操作是发布成功,且platform指定,则更新topic的platform_urls - if req.action == "publish" and req.status == "success" and req.platform and req.url: - if not topic.platform_urls: - topic.platform_urls = {} - topic.platform_urls[req.platform] = req.url - db.commit() - - return record + articles = db.query(Article).filter(Article.topic_id == topic_id).all() + return [a.to_dict() if hasattr(a, 'to_dict') else { + "id": a.id, "topic_id": a.topic_id, "platform": a.platform, + "status": a.status, "file_path": a.file_path + } for a in articles] -# 更新发布记录 -@router.put("/publish-records/{record_id}") -def update_publish_record(record_id: int, req: PublishActionRequest, db: Session = Depends(get_db)): - record = db.query(PublishRecord).filter(PublishRecord.id == record_id).first() - if not record: - raise HTTPException(status_code=404, detail="Record not found") - - # 更新字段 - for field, value in req.dict(exclude_unset=True).items(): - setattr(record, field, value) - record.updated_at = datetime.now() - db.commit() - - return record +@router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]]) +def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)): + metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all() + return [m.to_dict() for m in metrics] -@router.get("/{topic_id}/preview") -def preview_topic(topic_id: str, platform: str = Query("zhihu", regex="^(zhihu|wechat|xiaohongshu)$")): - """ - 预览某选题在指定平台的HTML内容。 - 查找最近发布的release文件。 - """ - # 查找最近的发布包 - releases_dir = PROJECT_ROOT / "automation" / "data" / "releases" - if not releases_dir.exists(): - raise HTTPException(status_code=404, detail="No releases found") - - # 按日期倒序查找 - dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True) - found = None - for dt in dates: - file_path = releases_dir / dt / platform / f"{platform}_{topic_id}_{platform}.html" - if file_path.exists(): - found = file_path - break - - if not found: - raise HTTPException(status_code=404, detail=f"Preview not found for topic {topic_id} on {platform}") - - content = found.read_text(encoding="utf-8") - return {"topic_id": topic_id, "platform": platform, "html": content} - - -@router.get("/{topic_id}/packages") -def list_packages(topic_id: str): - """ - 列出某选题的所有发布包(HTML文件)。 - """ - releases_dir = PROJECT_ROOT / "automation" / "data" / "releases" - if not releases_dir.exists(): - return {"packages": []} - - packages = [] - dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True) - for dt in dates: - date_dir = releases_dir / dt - for platform in ["zhihu", "wechat", "xiaohongshu"]: - file_path = date_dir / platform / f"{platform}_{topic_id}_{platform}.html" - if file_path.exists(): - stat = file_path.stat() - packages.append({ - "platform": platform, - "path": str(file_path.relative_to(PROJECT_ROOT)), - "size": stat.st_size, - "modified": datetime.fromtimestamp(stat.st_mtime).isoformat() - }) - - return {"packages": packages} - - -@router.delete("/{topic_id}") -def delete_topic(topic_id: str, db: Session = Depends(get_db)): - """删除选题""" +@router.post("/{topic_id}/lock") +def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): topic = db.query(Topic).filter(Topic.id == topic_id).first() if not topic: - raise HTTPException(status_code=404, detail="选题不存在") - - db.delete(topic) + raise HTTPException(status_code=404, detail="Topic not found") + topic.lock_by = current_user.username + topic.lock_at = datetime.now() db.commit() - return {"message": "删除成功", "topic_id": topic_id} + return {"ok": True} + + +@router.post("/{topic_id}/unlock") +def unlock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)): + topic = db.query(Topic).filter(Topic.id == topic_id).first() + if not topic: + raise HTTPException(status_code=404, detail="Topic not found") + topic.lock_by = None + topic.lock_at = None + db.commit() + return {"ok": True} + + +@router.post("/batch-update-status") +def batch_update_status( + topic_ids: List[str], + status: str, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + updated = db.query(Topic).filter(Topic.id.in_(topic_ids)).update( + {Topic.status: status, Topic.updated_at: datetime.now()}, + synchronize_session=False + ) + db.commit() + return {"ok": True, "updated": updated} + + +@router.get("/field-distribution") +def field_distribution( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + rows = db.query( + Topic.field_name, + func.count(Topic.id).label("count") + ).group_by(Topic.field_name).all() + return [{"field": r.field_name or "未分类", "count": r.count} for r in rows] \ No newline at end of file diff --git a/platform/backend/app/database.py b/platform/backend/app/database.py index 8f98b10..27e66f9 100644 --- a/platform/backend/app/database.py +++ b/platform/backend/app/database.py @@ -4,14 +4,11 @@ from sqlalchemy.orm import sessionmaker import os from pathlib import Path -# 计算项目根目录(backend/app/database.py -> yu-zhi-ran) PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent -# 数据库配置:通过环境变量控制 -USE_POSTGRES = os.getenv('USE_POSTGRES', 'false').lower() == 'true' +USE_POSTGRES = os.getenv('USE_POSTGRES', 'true').lower() == 'true' if USE_POSTGRES: - # PostgreSQL 生产数据库 POSTGRES_CONFIG = { 'host': os.getenv('PG_HOST', '127.0.0.1'), 'port': os.getenv('PG_PORT', '5432'), @@ -19,10 +16,17 @@ if USE_POSTGRES: 'user': os.getenv('PG_USER', 'yzr_nr'), 'password': os.getenv('PG_PASSWORD', 'aTX3WKKnPfRnM5PC') } - SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_CONFIG['user']}:{POSTGRES_CONFIG['password']}@{POSTGRES_CONFIG['host']}:{POSTGRES_CONFIG['port']}/{POSTGRES_CONFIG['database']}" - engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) + SQLALCHEMY_DATABASE_URL = ( + f"postgresql://{POSTGRES_CONFIG['user']}:{POSTGRES_CONFIG['password']}" + f"@{POSTGRES_CONFIG['host']}:{POSTGRES_CONFIG['port']}/{POSTGRES_CONFIG['database']}" + ) + engine = create_engine( + SQLALCHEMY_DATABASE_URL, + pool_pre_ping=True, + pool_size=10, + max_overflow=20 + ) else: - # SQLite 开发数据库 DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data')) os.makedirs(DATA_DIR, exist_ok=True) DB_PATH = os.path.join(DATA_DIR, 'yzr.db') diff --git a/platform/backend/app/initial_data.py b/platform/backend/app/initial_data.py index eefad8e..b1e98b1 100644 --- a/platform/backend/app/initial_data.py +++ b/platform/backend/app/initial_data.py @@ -3,85 +3,36 @@ import os from datetime import datetime from pathlib import Path from .database import SessionLocal, init_db -from .models import Topic, User, Case, LLMConfig, SystemConfig +from .models import ( + Topic, TopicField, TopicConfigField, TopicStatusConfig, + User, Case, LLMConfig, SystemConfig, PlatformConfig +) import bcrypt -# 计算项目根目录(backend/app/initial_data.py -> 上升3层到 yu-zhi-ran) PROJECT_ROOT = Path(__file__).resolve().parents[1] if os.getenv('PROJECT_ROOT'): PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT')) TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json" CASES_FILE = PROJECT_ROOT / "automation" / "data" / "initial_cases.json" -# 从环境变量读取管理员配置 DEFAULT_ADMIN_USERNAME = os.getenv('DEFAULT_ADMIN_USERNAME', 'admin') DEFAULT_ADMIN_PASSWORD = os.getenv('DEFAULT_ADMIN_PASSWORD', 'admin123') + def import_initial_data(): db = SessionLocal() try: - # 1. 导入选题数据 - if db.query(Topic).count() == 0: - if __import__('os').path.exists(TOPICS_FILE): - topics = json.loads(open(TOPICS_FILE, encoding='utf-8').read()) - # 去重:保留每个 ID 最后出现的记录 - seen = {} - for t in topics: - seen[t['id']] = t - unique_topics = list(seen.values()) - for t in unique_topics: - topic = Topic( - id=t['id'], - title=t['title'], - field=t['field'], - format=t.get('format'), - core_concept=t.get('core_concept'), - audience_pain=t.get('audience_pain'), - unique_angle=t.get('unique_angle'), - priority=t.get('priority'), - priority_score=t.get('priority_score', 0), - total_score=t.get('total_score'), - status=t.get('status', 'pending'), - cases=t.get('cases', []), - source_file=t.get('source_file'), - ready_at=datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else None, - published_at=datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else None, - compliance_score=t.get('compliance_score'), - platform_urls=t.get('platform_urls', {}) - ) - db.add(topic) - db.commit() - print(f"✅ 导入 {len(unique_topics)} 个选题到数据库(去重后)") - else: - print(f"⚠️ 选题文件不存在: {TOPICS_FILE}") - else: - print("数据库已有选题数据,跳过导入") + if db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first() is None: + hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()) + admin = User( + username=DEFAULT_ADMIN_USERNAME, + password_hash=hashed.decode('utf-8'), + role="admin" + ) + db.add(admin) + db.commit() + print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}") - - # 3. 导入案例数据(如果为空) - if db.query(Case).count() == 0: - if __import__('os').path.exists(CASES_FILE): - cases_data = json.loads(open(CASES_FILE, encoding='utf-8').read()) - for c in cases_data: - case = Case( - id=c['id'], - title=c['title'], - field=c['field'], - summary=c['summary'], - key_metrics=c.get('key_metrics'), - date=c.get('date'), - source=c['source'], - source_url=c.get('source_url'), - credibility_rating=c.get('credibility_rating'), - china_applicability=c.get('china_applicability') - ) - db.add(case) - db.commit() - print(f"✅ 导入 {len(cases_data)} 条案例") - else: - print(f"⚠️ 案例文件不存在: {CASES_FILE}") - - # 4. 插入 LLM 默认配置 if db.query(LLMConfig).count() == 0: default_llm = LLMConfig( name="default_expand", @@ -117,7 +68,6 @@ def import_initial_data(): db.commit() print("✅ 插入默认 LLM 配置") - # 5. 插入系统配置默认值 default_system_configs = [ {"key": "collector_enabled", "value": "false", "description": "是否启用采集器"}, {"key": "scheduler_interval", "value": "daily", "description": "调度间隔:daily/hourly/weekly"}, @@ -128,32 +78,148 @@ def import_initial_data(): db.commit() print("✅ 插入默认系统配置") -# 2. 创建默认管理员用户(bcrypt 哈希) - admin_exists = db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first() - if not admin_exists: - hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()) - admin = User( - username=DEFAULT_ADMIN_USERNAME, - password_hash=hashed.decode('utf-8'), - role="admin" - ) - db.add(admin) + if db.query(PlatformConfig).count() == 0: + platforms = [ + { + "platform": "zhihu", + "name": "知乎", + "icon": "🔍", + "default_format": "长文深度分析,1500-3000字,有数据支撑", + "compliance_rules": { + "max_length": 50000, + "requires_authentication": False, + "sensitive_words": ["敏感词示例1", "敏感词示例2"] + }, + "is_active": True + }, + { + "platform": "wechat", + "name": "微信公众号", + "icon": "💚", + "default_format": "公众号图文,800-1500字,亲切口语化", + "compliance_rules": { + "max_length": 20000, + "requires_authentication": True + }, + "is_active": True + }, + { + "platform": "xiaohongshu", + "name": "小红书", + "icon": "📕", + "default_format": "图文笔记,300-800字,emoji+标签", + "compliance_rules": { + "max_length": 1000, + "requires_tags": True, + "max_tags": 10 + }, + "is_active": True + } + ] + for p in platforms: + db.add(PlatformConfig(**p)) db.commit() - print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}") - else: - # 如果管理员已存在但密码为空,更新为默认密码的哈希 - if not admin_exists.password_hash: - hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()) - admin_exists.password_hash = hashed.decode('utf-8') + print("✅ 插入平台配置") + + if db.query(TopicField).count() == 0: + fields = [ + {"name": "未来工作方式", "icon": "💼", "color": "#667eea", "description": "远程工作、零工经济、职业转型", "sort_order": 1}, + {"name": "AI与效率", "icon": "🤖", "color": "#764ba2", "description": "AI工具、数字助手、效率方法", "sort_order": 2}, + {"name": "可持续生活", "icon": "🌿", "color": "#67c23a", "description": "环保、低碳、自然生活方式", "sort_order": 3}, + {"name": "数字游民", "icon": "🌍", "color": "#409eff", "description": "旅行、地理自由、海外生活", "sort_order": 4}, + {"name": "个人成长", "icon": "📚", "color": "#e6a23c", "description": "学习、技能、认知升级", "sort_order": 5}, + {"name": "科技人文", "icon": "🔬", "color": "#f56c6c", "description": "科技伦理、数字生活反思", "sort_order": 6}, + ] + for f in fields: + db.add(TopicField(**f)) + db.commit() + print("✅ 插入默认领域配置") + + if db.query(TopicStatusConfig).count() == 0: + statuses = [ + {"status": "pending", "label": "待处理", "color": "#E6A23C", "icon": "⏳", "sort_order": 1, "is_default": True}, + {"status": "review", "label": "待审查", "color": "#F56C6C", "icon": "🔍", "sort_order": 2}, + {"status": "draft", "label": "草稿", "color": "#909399", "icon": "📝", "sort_order": 3}, + {"status": "ready", "label": "待发布", "color": "#67C23A", "icon": "✅", "sort_order": 4}, + {"status": "published", "label": "已发布", "color": "#409EFF", "icon": "🚀", "sort_order": 5}, + ] + for s in statuses: + db.add(TopicStatusConfig(**s)) + db.commit() + print("✅ 插入状态配置") + + field_map = {} + for f in db.query(TopicField).all(): + field_map[f.name] = f.id + + if db.query(Topic).count() == 0: + if os.path.exists(TOPICS_FILE): + topics = json.loads(open(TOPICS_FILE, encoding='utf-8').read()) + seen = {} + for t in topics: + seen[t['id']] = t + unique_topics = list(seen.values()) + for t in unique_topics: + field_id = field_map.get(t.get('field')) + field_name = t.get('field') + topic = Topic( + id=t['id'], + field_id=field_id, + field_name=field_name, + title=t['title'], + format=t.get('format'), + core_concept=t.get('core_concept'), + audience_pain=t.get('audience_pain'), + unique_angle=t.get('unique_angle'), + priority=t.get('priority'), + priority_score=t.get('priority_score', 0), + total_score=t.get('total_score'), + status=t.get('status', 'pending'), + cases=t.get('cases', []), + tags=t.get('tags', []), + source_file=t.get('source_file'), + ready_at=datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else None, + published_at=datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else None, + compliance_score=t.get('compliance_score'), + platform_urls=t.get('platform_urls', {}) + ) + db.add(topic) db.commit() - print(f"✅ 更新管理员密码") - print(f"管理员已存在: {DEFAULT_ADMIN_USERNAME}") + print(f"✅ 导入 {len(unique_topics)} 个选题") + else: + print(f"⚠️ 选题文件不存在: {TOPICS_FILE}") + + if db.query(Case).count() == 0: + if os.path.exists(CASES_FILE): + cases_data = json.loads(open(CASES_FILE, encoding='utf-8').read()) + for c in cases_data: + case = Case( + id=c['id'], + title=c['title'], + field=c['field'], + summary=c['summary'], + key_metrics=c.get('key_metrics'), + date=c.get('date'), + source=c['source'], + source_url=c.get('source_url'), + credibility_rating=c.get('credibility_rating'), + china_applicability=c.get('china_applicability') + ) + db.add(case) + db.commit() + print(f"✅ 导入 {len(cases_data)} 条案例") + + print("✅ 初始化完成") + except Exception as e: + import traceback + traceback.print_exc() print(f"初始化失败: {e}") db.rollback() finally: db.close() + if __name__ == "__main__": init_db() - import_initial_data() + import_initial_data() \ No newline at end of file diff --git a/platform/backend/app/main.py b/platform/backend/app/main.py index 5ba6284..f1db531 100644 --- a/platform/backend/app/main.py +++ b/platform/backend/app/main.py @@ -9,7 +9,7 @@ from pathlib import Path from .database import engine, get_db, init_db from .models import Base -from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs +from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config from .initial_data import import_initial_data app = FastAPI(title="宇之然内容创作平台", version="0.1.0") @@ -42,6 +42,12 @@ app.include_router(cases.router) app.include_router(task_logs.router) app.include_router(llm_configs.router) app.include_router(system_configs.router) +app.include_router(topic_config.router) +app.include_router(calendar.router) +app.include_router(metrics.router) +app.include_router(assets.router) +app.include_router(tasks.router) +app.include_router(platform_config.router) # 挂载前端 FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend" diff --git a/platform/backend/app/models.py b/platform/backend/app/models.py index 82d4dfc..34ef769 100644 --- a/platform/backend/app/models.py +++ b/platform/backend/app/models.py @@ -1,5 +1,6 @@ -from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON +from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON, ForeignKey, UniqueConstraint from sqlalchemy.sql import func +from sqlalchemy.orm import relationship from .database import Base from datetime import datetime @@ -8,14 +9,14 @@ class AuditLog(Base): __tablename__ = "audit_logs" id = Column(Integer, primary_key=True, index=True, autoincrement=True) - user_id = Column(Integer, nullable=True, index=True) # 操作用户ID(未登录/匿名可为空) - username = Column(String, nullable=False) # 操作用户名(冗余存储) - action = Column(String, nullable=False, index=True) # 操作类型: login/logout/create_user/update_user/delete_user/publish/etc. - resource_type = Column(String, nullable=True, index=True) # 资源类型: user/topic/publish_record/etc. - resource_id = Column(String, nullable=True) # 资源ID - details = Column(JSON, default=dict, nullable=True) # 操作详情(变更前后、额外信息等) - ip_address = Column(String, nullable=True) # IP 地址 - user_agent = Column(String, nullable=True) # User-Agent + user_id = Column(Integer, nullable=True, index=True) + username = Column(String, nullable=False) + action = Column(String, nullable=False, index=True) + resource_type = Column(String, nullable=True, index=True) + resource_id = Column(String, nullable=True) + details = Column(JSON, default=dict, nullable=True) + ip_address = Column(String, nullable=True) + user_agent = Column(String, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) def to_dict(self): @@ -38,8 +39,8 @@ class User(Base): id = Column(Integer, primary_key=True, index=True, autoincrement=True) username = Column(String, unique=True, nullable=False, index=True) - password_hash = Column(String, nullable=False) # bcrypt 哈希 - role = Column(String, default="user", nullable=False) # admin/user + password_hash = Column(String, nullable=False) + role = Column(String, default="user", nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -52,62 +53,363 @@ class User(Base): } +class TopicField(Base): + __tablename__ = "topic_fields" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String, nullable=False) + icon = Column(String, nullable=True) + color = Column(String, nullable=True) + description = Column(Text, nullable=True) + parent_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True) + sort_order = Column(Integer, default=0) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + parent = relationship("TopicField", remote_side=[id], backref="children") + scoring_fields = relationship("TopicConfigField", back_populates="field", cascade="all, delete-orphan") + + def to_dict(self): + return { + "id": self.id, + "name": self.name, + "icon": self.icon, + "color": self.color, + "description": self.description, + "parent_id": self.parent_id, + "sort_order": self.sort_order, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class TopicConfigField(Base): + __tablename__ = "topic_config_fields" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=False) + name = Column(String, nullable=False) + key = Column(String, nullable=False) + field_type = Column(String, default="number") # number/select/multi_select/text + weight = Column(Float, default=1.0) + options = Column(JSON, default=list) # for select/multi_select + min_value = Column(Float, nullable=True) + max_value = Column(Float, nullable=True) + is_required = Column(Boolean, default=False) + sort_order = Column(Integer, default=0) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + field = relationship("TopicField", back_populates="scoring_fields") + + def to_dict(self): + return { + "id": self.id, + "field_id": self.field_id, + "name": self.name, + "key": self.key, + "field_type": self.field_type, + "weight": self.weight, + "options": self.options or [], + "min_value": self.min_value, + "max_value": self.max_value, + "is_required": self.is_required, + "sort_order": self.sort_order, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + +class TopicStatusConfig(Base): + __tablename__ = "topic_status_configs" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + status = Column(String, unique=True, nullable=False) + label = Column(String, nullable=False) + color = Column(String, nullable=True) + icon = Column(String, nullable=True) + sort_order = Column(Integer, default=0) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + def to_dict(self): + return { + "id": self.id, + "status": self.status, + "label": self.label, + "color": self.color, + "icon": self.icon, + "sort_order": self.sort_order, + "is_default": self.is_default, + "created_at": self.created_at.isoformat() if self.created_at else None, + } + + class Topic(Base): __tablename__ = "topics" id = Column(String, primary_key=True, index=True) + field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True) + field_name = Column(String, nullable=True) title = Column(String, nullable=False) - field = Column(String, nullable=False) format = Column(String) core_concept = Column(Text) audience_pain = Column(Text) unique_angle = Column(Text) - priority = Column(String) # 高/中 + priority = Column(String) priority_score = Column(Integer, default=0) total_score = Column(Float) - status = Column(String, default="pending") # pending/draft/ready/published + status = Column(String, default="pending") cases = Column(JSON, default=list) source_file = Column(String) + tags = Column(JSON, default=list) + custom_data = Column(JSON, default=dict) + scoring_data = Column(JSON, default=dict) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - generated_at = Column(DateTime(timezone=True), nullable=True) # 选题创作完成时间 + generated_at = Column(DateTime(timezone=True), nullable=True) ready_at = Column(Date) published_at = Column(Date) compliance_score = Column(Integer) - platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."} + platform_urls = Column(JSON, default=dict) + lock_by = Column(String, nullable=True) + lock_at = Column(DateTime, nullable=True) + + field = relationship("TopicField", backref="topics") + + @property + def field_info(self): + if self.field: + return self.field.to_dict() + return None class Article(Base): __tablename__ = "articles" - id = Column(String, primary_key=True) # e.g., A01_zhihu - topic_id = Column(String, nullable=False) + id = Column(String, primary_key=True) + topic_id = Column(String, ForeignKey("topics.id"), nullable=False) platform = Column(String, nullable=False) file_path = Column(String, nullable=False) - status = Column(String, default="draft") # draft/optimized/published + status = Column(String, default="draft") created_at = Column(DateTime(timezone=True), server_default=func.now()) compliance_score = Column(Integer) - html_content = Column(Text) # 可缓存HTML内容以便预览 + html_content = Column(Text) + word_count = Column(Integer, nullable=True) + outline = Column(Text, nullable=True) class PublishRecord(Base): __tablename__ = "publish_records" id = Column(Integer, primary_key=True, index=True) - topic_id = Column(String, nullable=False) - platform = Column(String, nullable=False) # 发布平台:zhihu/wechat/xiaohongshu - action = Column(String, nullable=False) # 操作:publish/update/delete/invalid - status = Column(String, nullable=False) # 状态:success/failed/partial/cancelled - operator = Column(String, nullable=True) # 操作人 - description = Column(Text, nullable=True) # 发布说明 - suggestion = Column(Text, nullable=True) # 建议内容 - url = Column(String, nullable=True) # 发布后链接 - error_msg = Column(Text, nullable=True) # 错误信息 + topic_id = Column(String, ForeignKey("topics.id"), nullable=False) + platform = Column(String, nullable=False) + action = Column(String, nullable=False) + status = Column(String, nullable=False) + operator = Column(String, nullable=True) + description = Column(Text, nullable=True) + suggestion = Column(Text, nullable=True) + url = Column(String, nullable=True) + error_msg = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) - # 关联查询 - # 可以添加外键关联到 Topic, 但这里保持简单 + topic = relationship("Topic") + + +class ContentCalendar(Base): + __tablename__ = "content_calendar" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + topic_id = Column(String, ForeignKey("topics.id"), nullable=True) + field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True) + title = Column(String, nullable=False) + planned_date = Column(Date, nullable=False, index=True) + published_date = Column(Date, nullable=True) + platform = Column(String, nullable=True) + status = Column(String, default="planned") # planned/published/delayed/cancelled + reminder_time = Column(DateTime, nullable=True) + notes = Column(Text, nullable=True) + created_by = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + topic = relationship("Topic") + + def to_dict(self): + return { + "id": self.id, + "topic_id": self.topic_id, + "field_id": self.field_id, + "title": self.title, + "planned_date": self.planned_date.isoformat() if self.planned_date else None, + "published_date": self.published_date.isoformat() if self.published_date else None, + "platform": self.platform, + "status": self.status, + "reminder_time": self.reminder_time.isoformat() if self.reminder_time else None, + "notes": self.notes, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class ContentMetrics(Base): + __tablename__ = "content_metrics" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + topic_id = Column(String, ForeignKey("topics.id"), nullable=False, index=True) + platform = Column(String, nullable=False) + publish_url = Column(String, nullable=True) + views = Column(Integer, default=0) + likes = Column(Integer, default=0) + favorites = Column(Integer, default=0) + comments = Column(Integer, default=0) + shares = Column(Integer, default=0) + last_fetched = Column(DateTime(timezone=True), nullable=True) + data_snapshot = Column(JSON, default=dict) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + topic = relationship("Topic") + + @property + def engagement_rate(self): + total = self.views or 0 + if total == 0: + return 0 + return round((self.likes or 0) / total * 100, 2) + + def to_dict(self): + return { + "id": self.id, + "topic_id": self.topic_id, + "platform": self.platform, + "publish_url": self.publish_url, + "views": self.views, + "likes": self.likes, + "favorites": self.favorites, + "comments": self.comments, + "shares": self.shares, + "engagement_rate": self.engagement_rate, + "last_fetched": self.last_fetched.isoformat() if self.last_fetched else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class MediaAsset(Base): + __tablename__ = "media_assets" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + filename = Column(String, nullable=False) + file_path = Column(String, nullable=False) + file_url = Column(String, nullable=True) + file_type = Column(String, nullable=False) # image/video/document + mime_type = Column(String, nullable=True) + size = Column(Integer, nullable=True) + width = Column(Integer, nullable=True) + height = Column(Integer, nullable=True) + thumbnail_path = Column(String, nullable=True) + alt_text = Column(String, nullable=True) + tags = Column(JSON, default=list) + topic_ids = Column(JSON, default=list) + usage_count = Column(Integer, default=0) + uploaded_by = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + def to_dict(self): + return { + "id": self.id, + "filename": self.filename, + "file_path": self.file_path, + "file_url": self.file_url, + "file_type": self.file_type, + "mime_type": self.mime_type, + "size": self.size, + "width": self.width, + "height": self.height, + "thumbnail_path": self.thumbnail_path, + "alt_text": self.alt_text, + "tags": self.tags or [], + "topic_ids": self.topic_ids or [], + "usage_count": self.usage_count, + "uploaded_by": self.uploaded_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class PlatformConfig(Base): + __tablename__ = "platform_configs" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + platform = Column(String, unique=True, nullable=False) # zhihu/wechat/xiaohongshu + name = Column(String, nullable=False) + icon = Column(String, nullable=True) + api_endpoint = Column(String, nullable=True) + auth_config = Column(JSON, default=dict) + format_template = Column(JSON, default=dict) + compliance_rules = Column(JSON, default=dict) + default_format = Column(Text, nullable=True) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + def to_dict(self): + return { + "id": self.id, + "platform": self.platform, + "name": self.name, + "icon": self.icon, + "api_endpoint": self.api_endpoint, + "format_template": self.format_template or {}, + "compliance_rules": self.compliance_rules or {}, + "default_format": self.default_format, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + +class ContentTask(Base): + __tablename__ = "content_tasks" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + topic_id = Column(String, ForeignKey("topics.id"), nullable=True, index=True) + task_id = Column(String, unique=True, nullable=False) + stage = Column(String, nullable=False) # research/outline/writer/optimizer/format/publish + status = Column(String, default="pending") # pending/running/completed/failed/cancelled + progress = Column(Integer, default=0) + message = Column(Text, nullable=True) + result_data = Column(JSON, default=dict) + error_msg = Column(Text, nullable=True) + started_at = Column(DateTime(timezone=True), nullable=True) + finished_at = Column(DateTime(timezone=True), nullable=True) + duration = Column(Integer, nullable=True) + created_by = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + topic = relationship("Topic") + + def to_dict(self): + return { + "id": self.id, + "topic_id": self.topic_id, + "task_id": self.task_id, + "stage": self.stage, + "status": self.status, + "progress": self.progress, + "message": self.message, + "error_msg": self.error_msg, + "started_at": self.started_at.isoformat() if self.started_at else None, + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + "duration": self.duration, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + } class Case(Base): @@ -115,13 +417,13 @@ class Case(Base): id = Column(Integer, primary_key=True, index=True, autoincrement=True) title = Column(String, nullable=False) - field = Column(String, nullable=False) # 对应四大支柱或其子领域 + field = Column(String, nullable=False) summary = Column(Text) - key_metrics = Column(Text, nullable=True) # 关键数据 - date = Column(String, nullable=True) # 日期或年份字符串 + key_metrics = Column(Text, nullable=True) + date = Column(String, nullable=True) source = Column(String, nullable=True) - credibility_rating = Column(String, nullable=True) # 如 "⭐⭐⭐" - china_applicability = Column(String, nullable=True) # 如 "⭐⭐⭐⭐" + credibility_rating = Column(String, nullable=True) + china_applicability = Column(String, nullable=True) source_url = Column(String, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -147,13 +449,13 @@ class TaskLog(Base): __tablename__ = "task_logs" id = Column(Integer, primary_key=True, index=True, autoincrement=True) - task_name = Column(String, nullable=False) # collector, creator, research, outline, writer, optimizer - topic_id = Column(String, nullable=True) # 关联选题ID - status = Column(String, nullable=False) # started, completed, failed + task_name = Column(String, nullable=False) + topic_id = Column(String, nullable=True) + status = Column(String, nullable=False) message = Column(Text, nullable=True) started_at = Column(DateTime(timezone=True), server_default=func.now()) finished_at = Column(DateTime(timezone=True), nullable=True) - duration = Column(Integer, nullable=True) # 秒数 + duration = Column(Integer, nullable=True) def to_dict(self): return { @@ -172,12 +474,12 @@ class LLMConfig(Base): __tablename__ = "llm_configs" id = Column(Integer, primary_key=True, index=True, autoincrement=True) - name = Column(String, unique=True, nullable=False) # 如 default_expand + name = Column(String, unique=True, nullable=False) system_prompt = Column(Text, nullable=True) - user_prompt_template = Column(Text, nullable=False) # 含占位符 {topic.get('title')} 等 + user_prompt_template = Column(Text, nullable=False) temperature = Column(Float, default=0.7) max_tokens = Column(Integer, default=2000) - model = Column(String, nullable=True) # 后端模型名 + model = Column(String, nullable=True) is_active = Column(Boolean, default=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -202,7 +504,7 @@ class SystemConfig(Base): id = Column(Integer, primary_key=True, index=True, autoincrement=True) key = Column(String, unique=True, nullable=False) - value = Column(Text, nullable=True) # 可存储 JSON 字符串 + value = Column(Text, nullable=True) description = Column(String, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) @@ -216,7 +518,4 @@ class SystemConfig(Base): "description": self.description, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, - } - - -# --- 新增模型:案例库 --- + } \ No newline at end of file diff --git a/platform/backend/app/schemas.py b/platform/backend/app/schemas.py index e89920a..6603a19 100644 --- a/platform/backend/app/schemas.py +++ b/platform/backend/app/schemas.py @@ -1,7 +1,336 @@ -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field from datetime import datetime, date from typing import Optional, List, Dict, Any + +class TopicFieldBase(BaseModel): + name: str + icon: Optional[str] = None + color: Optional[str] = None + description: Optional[str] = None + parent_id: Optional[int] = None + sort_order: int = 0 + is_active: bool = True + + +class TopicFieldResponse(TopicFieldBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class TopicConfigFieldBase(BaseModel): + name: str + key: str + field_type: str = "number" + weight: float = 1.0 + options: List[Any] = [] + min_value: Optional[float] = None + max_value: Optional[float] = None + is_required: bool = False + sort_order: int = 0 + + +class TopicConfigFieldResponse(TopicConfigFieldBase): + id: int + field_id: int + created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class TopicStatusConfigBase(BaseModel): + status: str + label: str + color: Optional[str] = None + icon: Optional[str] = None + sort_order: int = 0 + is_default: bool = False + + +class TopicStatusConfigResponse(TopicStatusConfigBase): + id: int + created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class TopicBase(BaseModel): + id: str + field_id: Optional[int] = None + field_name: Optional[str] = None + title: str + format: Optional[str] = None + core_concept: Optional[str] = None + audience_pain: Optional[str] = None + unique_angle: Optional[str] = None + priority: Optional[str] = None + priority_score: int = 0 + total_score: Optional[float] = None + status: str = "pending" + tags: List[str] = [] + custom_data: Dict[str, Any] = {} + scoring_data: Dict[str, Any] = {} + lock_by: Optional[str] = None + lock_at: Optional[datetime] = None + + +class TopicCreate(BaseModel): + id: Optional[str] = None + field_id: Optional[int] = None + title: str + format: Optional[str] = None + core_concept: Optional[str] = None + audience_pain: Optional[str] = None + unique_angle: Optional[str] = None + priority: Optional[str] = None + tags: List[str] = [] + custom_data: Dict[str, Any] = {} + scoring_data: Dict[str, Any] = {} + + +class TopicUpdate(BaseModel): + field_id: Optional[int] = None + title: Optional[str] = None + format: Optional[str] = None + core_concept: Optional[str] = None + audience_pain: Optional[str] = None + unique_angle: Optional[str] = None + priority: Optional[str] = None + status: Optional[str] = None + tags: Optional[List[str]] = None + custom_data: Optional[Dict[str, Any]] = None + scoring_data: Optional[Dict[str, Any]] = None + priority_score: Optional[int] = None + total_score: Optional[float] = None + + +class TopicResponse(TopicBase): + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + generated_at: Optional[datetime] = None + ready_at: Optional[date] = None + published_at: Optional[date] = None + compliance_score: Optional[int] = None + platform_urls: Dict[str, str] = {} + cases: List[Any] = [] + + model_config = ConfigDict(from_attributes=True) + + +class TopicScoreRequest(BaseModel): + scoring_data: Dict[str, Any] + + +class ArticleBase(BaseModel): + id: str + topic_id: str + platform: str + file_path: str + status: str = "draft" + compliance_score: Optional[int] = None + word_count: Optional[int] = None + outline: Optional[str] = None + created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class ArticleResponse(ArticleBase): + html_content: Optional[str] = None + + model_config = ConfigDict(from_attributes=True) + + +class ContentCalendarBase(BaseModel): + topic_id: Optional[str] = None + field_id: Optional[int] = None + title: str + planned_date: date + platform: Optional[str] = None + status: str = "planned" + reminder_time: Optional[datetime] = None + notes: Optional[str] = None + + +class ContentCalendarCreate(ContentCalendarBase): + created_by: Optional[str] = None + + +class ContentCalendarUpdate(BaseModel): + topic_id: Optional[str] = None + title: Optional[str] = None + planned_date: Optional[date] = None + published_date: Optional[date] = None + platform: Optional[str] = None + status: Optional[str] = None + reminder_time: Optional[datetime] = None + notes: Optional[str] = None + + +class ContentCalendarResponse(ContentCalendarBase): + id: int + published_date: Optional[date] = None + created_by: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class ContentMetricsBase(BaseModel): + topic_id: str + platform: str + publish_url: Optional[str] = None + views: int = 0 + likes: int = 0 + favorites: int = 0 + comments: int = 0 + shares: int = 0 + + +class ContentMetricsCreate(ContentMetricsBase): + data_snapshot: Optional[Dict[str, Any]] = None + + +class ContentMetricsUpdate(BaseModel): + publish_url: Optional[str] = None + views: Optional[int] = None + likes: Optional[int] = None + favorites: Optional[int] = None + comments: Optional[int] = None + shares: Optional[int] = None + data_snapshot: Optional[Dict[str, Any]] = None + + +class ContentMetricsResponse(ContentMetricsBase): + id: int + engagement_rate: float = 0 + last_fetched: Optional[datetime] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class MediaAssetBase(BaseModel): + filename: str + file_path: str + file_url: Optional[str] = None + file_type: str + mime_type: Optional[str] = None + size: Optional[int] = None + width: Optional[int] = None + height: Optional[int] = None + thumbnail_path: Optional[str] = None + alt_text: Optional[str] = None + tags: List[str] = [] + topic_ids: List[str] = [] + + +class MediaAssetCreate(MediaAssetBase): + uploaded_by: Optional[str] = None + + +class MediaAssetUpdate(BaseModel): + alt_text: Optional[str] = None + tags: Optional[List[str]] = None + topic_ids: Optional[List[str]] = None + + +class MediaAssetResponse(MediaAssetBase): + id: int + usage_count: int = 0 + uploaded_by: Optional[str] = None + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class PlatformConfigBase(BaseModel): + platform: str + name: str + icon: Optional[str] = None + api_endpoint: Optional[str] = None + auth_config: Dict[str, Any] = {} + format_template: Dict[str, Any] = {} + compliance_rules: Dict[str, Any] = {} + default_format: Optional[str] = None + is_active: bool = True + + +class PlatformConfigCreate(PlatformConfigBase): + pass + + +class PlatformConfigUpdate(BaseModel): + name: Optional[str] = None + icon: Optional[str] = None + api_endpoint: Optional[str] = None + auth_config: Optional[Dict[str, Any]] = None + format_template: Optional[Dict[str, Any]] = None + compliance_rules: Optional[Dict[str, Any]] = None + default_format: Optional[str] = None + is_active: Optional[bool] = None + + +class PlatformConfigResponse(PlatformConfigBase): + id: int + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class ContentTaskBase(BaseModel): + topic_id: Optional[str] = None + stage: str + status: str = "pending" + progress: int = 0 + message: Optional[str] = None + + +class ContentTaskCreate(BaseModel): + topic_id: Optional[str] = None + stage: str + created_by: Optional[str] = None + + +class ContentTaskResponse(ContentTaskBase): + id: int + task_id: str + result_data: Dict[str, Any] = {} + error_msg: Optional[str] = None + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + duration: Optional[int] = None + created_by: Optional[str] = None + created_at: Optional[datetime] = None + + model_config = ConfigDict(from_attributes=True) + + +class MetricsDashboard(BaseModel): + total_topics: int + topics_by_status: Dict[str, int] + total_published: int + total_views: int + total_likes: int + avg_engagement_rate: float + top_topics: List[Dict[str, Any]] + recent_metrics: List[ContentMetricsResponse] + + +class CalendarMonthResponse(BaseModel): + year: int + month: int + entries: List[ContentCalendarResponse] + stats: Dict[str, int] + + class AuditLogBase(BaseModel): user_id: Optional[int] = None username: str @@ -13,85 +342,51 @@ class AuditLogBase(BaseModel): user_agent: Optional[str] = None created_at: Optional[datetime] = None + model_config = ConfigDict(from_attributes=True) + + class AuditLogResponse(AuditLogBase): id: int - model_config = ConfigDict(from_attributes=True) - class UserBase(BaseModel): username: str role: str = "user" + class UserCreate(UserBase): password: str + class UserUpdate(BaseModel): username: Optional[str] = None password: Optional[str] = None role: Optional[str] = None + class UserResponse(UserBase): id: int created_at: Optional[datetime] = None model_config = ConfigDict(from_attributes=True) + class LoginRequest(BaseModel): username: str password: str + class TokenResponse(BaseModel): token: str role: str user: UserResponse -class TopicBase(BaseModel): - id: str - title: str - field: str - priority_score: int = 0 - status: str = "pending" - compliance_score: Optional[int] = None - ready_at: Optional[date] = None - published_at: Optional[date] = None - generated_at: Optional[datetime] = None - platform_urls: Optional[Dict[str, str]] = None - -class TopicResponse(TopicBase): - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - model_config = ConfigDict(from_attributes=True) - -class ArticleBase(BaseModel): - id: str - topic_id: str - platform: str - file_path: str - status: str = "draft" - compliance_score: Optional[int] = None - created_at: Optional[datetime] = None - -class ArticleResponse(ArticleBase): - model_config = ConfigDict(from_attributes=True) - -class SystemStatus(BaseModel): - total_topics: int - topics_by_status: Dict[str, int] - ready_topics: List[TopicResponse] - today_articles: int - compliance_rate: float - last_optimization: Optional[datetime] = None - execution_time: Optional[float] = None - -class OptimizationRequest(BaseModel): - topic_ids: Optional[List[str]] = None class PublishRequest(BaseModel): topic_id: str platform_urls: Dict[str, str] + class PublishActionRequest(BaseModel): action: str platform: Optional[str] = None @@ -102,6 +397,7 @@ class PublishActionRequest(BaseModel): url: Optional[str] = None error_msg: Optional[str] = None + class PublishRecordResponse(BaseModel): id: int topic_id: str @@ -117,26 +413,7 @@ class PublishRecordResponse(BaseModel): model_config = ConfigDict(from_attributes=True) -class BatchPublishRequest(BaseModel): - date: str - -class AuditLogResponse(BaseModel): - id: int - user_id: Optional[int] = None - username: str - action: str - resource_type: Optional[str] = None - resource_id: Optional[str] = None - details: Optional[Dict[str, Any]] = None - ip_address: Optional[str] = None - user_agent: Optional[str] = None - created_at: datetime - - model_config = ConfigDict(from_attributes=True) - - -# --- 案例库 Schema --- class CaseBase(BaseModel): title: str field: str @@ -148,6 +425,7 @@ class CaseBase(BaseModel): credibility_rating: Optional[str] = None china_applicability: Optional[str] = None + class CaseResponse(CaseBase): id: int created_at: Optional[datetime] = None @@ -156,7 +434,6 @@ class CaseResponse(CaseBase): model_config = ConfigDict(from_attributes=True) -# --- 任务日志 Schema --- class TaskLogBase(BaseModel): task_name: str topic_id: Optional[str] = None @@ -166,13 +443,13 @@ class TaskLogBase(BaseModel): finished_at: Optional[datetime] = None duration_seconds: Optional[int] = None + class TaskLogResponse(TaskLogBase): id: int model_config = ConfigDict(from_attributes=True) -# --- LLM 配置 Schema --- class LLMConfigBase(BaseModel): name: str system_prompt: Optional[str] = None @@ -182,6 +459,7 @@ class LLMConfigBase(BaseModel): model: Optional[str] = None is_active: bool = True + class LLMConfigResponse(LLMConfigBase): id: int created_at: Optional[datetime] = None @@ -190,14 +468,31 @@ class LLMConfigResponse(LLMConfigBase): model_config = ConfigDict(from_attributes=True) -# --- 系统配置 Schema --- class SystemConfigBase(BaseModel): key: str value: Optional[str] = None description: Optional[str] = None + class SystemConfigResponse(SystemConfigBase): updated_at: Optional[datetime] = None model_config = ConfigDict(from_attributes=True) + +class SystemStatus(BaseModel): + total_topics: int + topics_by_status: Dict[str, int] + ready_topics: List[TopicResponse] + today_articles: int + compliance_rate: float + last_optimization: Optional[datetime] = None + execution_time: Optional[float] = None + + +class OptimizationRequest(BaseModel): + topic_ids: Optional[List[str]] = None + + +class BatchPublishRequest(BaseModel): + date: str \ No newline at end of file diff --git a/platform/backend/migrate.py b/platform/backend/migrate.py new file mode 100644 index 0000000..7e6b132 --- /dev/null +++ b/platform/backend/migrate.py @@ -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() \ No newline at end of file