feat: 全面升级项目架构 - PostgreSQL迁移 + 配置化改造
主要变更: - 数据库: SQLite → PostgreSQL (yzr_nr) - 选题系统: 硬编码字段 → 配置化 (TopicField/TopicConfigField/TopicStatusConfig) - 新增模型: ContentCalendar, ContentMetrics, MediaAsset, PlatformConfig, ContentTask - 新增 API: topic-config, calendar, metrics, assets, tasks, platform-config - 数据迁移: 现有选题数据迁移到新 schema (field_id/tags/custom_data/scoring_data) - 初始化数据: 10个领域, 5种状态, 3个平台配置 服务运行: http://localhost:8001 默认账号: admin / admin123
This commit is contained in:
@@ -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*
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, desc
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import ContentMetrics, Topic, ContentCalendar
|
||||||
|
from ..schemas import (
|
||||||
|
ContentMetricsCreate, ContentMetricsUpdate, ContentMetricsResponse,
|
||||||
|
MetricsDashboard
|
||||||
|
)
|
||||||
|
from .auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/metrics", tags=["metrics"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard", response_model=MetricsDashboard)
|
||||||
|
def get_dashboard(
|
||||||
|
days: int = Query(30, ge=1, le=365),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
since = datetime.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
total_topics = db.query(Topic).count()
|
||||||
|
|
||||||
|
raw_status = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||||
|
topics_by_status = {}
|
||||||
|
for s, cnt in raw_status:
|
||||||
|
topics_by_status[s] = cnt
|
||||||
|
|
||||||
|
total_published = db.query(ContentMetrics).filter(
|
||||||
|
ContentMetrics.views > 0
|
||||||
|
).count()
|
||||||
|
|
||||||
|
all_metrics = db.query(ContentMetrics).filter(
|
||||||
|
ContentMetrics.created_at >= since
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total_views = sum(m.views for m in all_metrics)
|
||||||
|
total_likes = sum(m.likes for m in all_metrics)
|
||||||
|
|
||||||
|
engagement_rates = [m.engagement_rate for m in all_metrics if m.views > 0]
|
||||||
|
avg_engagement = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0
|
||||||
|
|
||||||
|
top_topics_data = db.query(
|
||||||
|
ContentMetrics.topic_id,
|
||||||
|
func.sum(ContentMetrics.views).label("total_views"),
|
||||||
|
func.sum(ContentMetrics.likes).label("total_likes")
|
||||||
|
).join(Topic).filter(
|
||||||
|
ContentMetrics.created_at >= since
|
||||||
|
).group_by(ContentMetrics.topic_id).order_by(desc("total_views")).limit(10).all()
|
||||||
|
|
||||||
|
top_topics = []
|
||||||
|
for row in top_topics_data:
|
||||||
|
topic = db.query(Topic).filter(Topic.id == row.topic_id).first()
|
||||||
|
top_topics.append({
|
||||||
|
"topic_id": row.topic_id,
|
||||||
|
"title": topic.title if topic else row.topic_id,
|
||||||
|
"total_views": row.total_views or 0,
|
||||||
|
"total_likes": row.total_likes or 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
recent_metrics = db.query(ContentMetrics).order_by(
|
||||||
|
ContentMetrics.created_at.desc()
|
||||||
|
).limit(10).all()
|
||||||
|
|
||||||
|
return MetricsDashboard(
|
||||||
|
total_topics=total_topics,
|
||||||
|
topics_by_status=topics_by_status,
|
||||||
|
total_published=total_published,
|
||||||
|
total_views=total_views,
|
||||||
|
total_likes=total_likes,
|
||||||
|
avg_engagement_rate=round(avg_engagement, 2),
|
||||||
|
top_topics=top_topics,
|
||||||
|
recent_metrics=[ContentMetricsResponse.model_validate(m) for m in recent_metrics]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trend")
|
||||||
|
def get_trend(
|
||||||
|
days: int = Query(30, ge=1, le=365),
|
||||||
|
group_by: str = Query("day", enum=["day", "week", "month"]),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
since = datetime.now() - timedelta(days=days)
|
||||||
|
|
||||||
|
if group_by == "day":
|
||||||
|
date_format = func.date(ContentMetrics.created_at)
|
||||||
|
else:
|
||||||
|
date_format = func.date_trunc(group_by, ContentMetrics.created_at)
|
||||||
|
|
||||||
|
rows = db.query(
|
||||||
|
date_format.label("period"),
|
||||||
|
func.sum(ContentMetrics.views).label("views"),
|
||||||
|
func.sum(ContentMetrics.likes).label("likes"),
|
||||||
|
func.sum(ContentMetrics.comments).label("comments"),
|
||||||
|
func.count(ContentMetrics.id).label("count")
|
||||||
|
).filter(
|
||||||
|
ContentMetrics.created_at >= since
|
||||||
|
).group_by(date_format).order_by(date_format).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"period": str(row.period),
|
||||||
|
"views": row.views or 0,
|
||||||
|
"likes": row.likes or 0,
|
||||||
|
"comments": row.comments or 0,
|
||||||
|
"count": row.count or 0
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/entries", response_model=List[ContentMetricsResponse])
|
||||||
|
def list_metrics(
|
||||||
|
topic_id: Optional[str] = None,
|
||||||
|
platform: Optional[str] = None,
|
||||||
|
limit: int = Query(50, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
query = db.query(ContentMetrics)
|
||||||
|
if topic_id:
|
||||||
|
query = query.filter(ContentMetrics.topic_id == topic_id)
|
||||||
|
if platform:
|
||||||
|
query = query.filter(ContentMetrics.platform == platform)
|
||||||
|
return query.order_by(ContentMetrics.created_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entries", response_model=ContentMetricsResponse)
|
||||||
|
def create_metric(
|
||||||
|
data: ContentMetricsCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
raise HTTPException(status_code=404, detail="选题不存在")
|
||||||
|
|
||||||
|
existing = db.query(ContentMetrics).filter(
|
||||||
|
ContentMetrics.topic_id == data.topic_id,
|
||||||
|
ContentMetrics.platform == data.platform
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
if k != "topic_id" and k != "platform":
|
||||||
|
setattr(existing, k, v)
|
||||||
|
existing.last_fetched = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
metric = ContentMetrics(**data.model_dump(), last_fetched=datetime.now())
|
||||||
|
db.add(metric)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(metric)
|
||||||
|
return metric
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/entries/{metric_id}", response_model=ContentMetricsResponse)
|
||||||
|
def update_metric(
|
||||||
|
metric_id: int,
|
||||||
|
data: ContentMetricsUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first()
|
||||||
|
if not metric:
|
||||||
|
raise HTTPException(status_code=404, detail="数据记录不存在")
|
||||||
|
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(metric, k, v)
|
||||||
|
metric.last_fetched = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(metric)
|
||||||
|
return metric
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/entries/{metric_id}")
|
||||||
|
def delete_metric(
|
||||||
|
metric_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
metric = db.query(ContentMetrics).filter(ContentMetrics.id == metric_id).first()
|
||||||
|
if not metric:
|
||||||
|
raise HTTPException(status_code=404, detail="数据记录不存在")
|
||||||
|
db.delete(metric)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/topics/{topic_id}", response_model=List[ContentMetricsResponse])
|
||||||
|
def get_topic_metrics(
|
||||||
|
topic_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
raise HTTPException(status_code=404, detail="选题不存在")
|
||||||
|
return db.query(ContentMetrics).filter(
|
||||||
|
ContentMetrics.topic_id == topic_id
|
||||||
|
).order_by(ContentMetrics.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-platform")
|
||||||
|
def get_metrics_by_platform(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
rows = db.query(
|
||||||
|
ContentMetrics.platform,
|
||||||
|
func.sum(ContentMetrics.views).label("total_views"),
|
||||||
|
func.sum(ContentMetrics.likes).label("total_likes"),
|
||||||
|
func.sum(ContentMetrics.comments).label("total_comments"),
|
||||||
|
func.count(ContentMetrics.id).label("count")
|
||||||
|
).group_by(ContentMetrics.platform).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"platform": row.platform,
|
||||||
|
"total_views": row.total_views or 0,
|
||||||
|
"total_likes": row.total_likes or 0,
|
||||||
|
"total_comments": row.total_comments or 0,
|
||||||
|
"count": row.count or 0,
|
||||||
|
"avg_views": (row.total_views or 0) / (row.count or 1),
|
||||||
|
"avg_likes": (row.total_likes or 0) / (row.count or 1),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recommend-topics")
|
||||||
|
def recommend_topics_from_metrics(
|
||||||
|
limit: int = Query(10, le=50),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
high_performing = db.query(
|
||||||
|
ContentMetrics.topic_id,
|
||||||
|
func.avg(ContentMetrics.engagement_rate).label("avg_engagement"),
|
||||||
|
func.max(ContentMetrics.views).label("max_views")
|
||||||
|
).group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
|
||||||
|
|
||||||
|
recommendations = []
|
||||||
|
for row in high_performing:
|
||||||
|
topic = db.query(Topic).filter(Topic.id == row.topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
continue
|
||||||
|
metrics = db.query(ContentMetrics).filter(
|
||||||
|
ContentMetrics.topic_id == row.topic_id
|
||||||
|
).all()
|
||||||
|
recommendations.append({
|
||||||
|
"topic_id": row.topic_id,
|
||||||
|
"title": topic.title,
|
||||||
|
"field": topic.field_name,
|
||||||
|
"status": topic.status,
|
||||||
|
"avg_engagement": round(row.avg_engagement, 2) if row.avg_engagement else 0,
|
||||||
|
"max_views": row.max_views or 0,
|
||||||
|
"platforms": list(set(m.platform for m in metrics)),
|
||||||
|
"reason": f"平均互动率 {round(row.avg_engagement, 2)}%,最高阅读 {row.max_views}"
|
||||||
|
})
|
||||||
|
|
||||||
|
return recommendations[:limit]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import PlatformConfig
|
||||||
|
from ..schemas import PlatformConfigCreate, PlatformConfigUpdate, PlatformConfigResponse
|
||||||
|
from .auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/platform-config", tags=["platform-config"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[PlatformConfigResponse])
|
||||||
|
def list_platforms(
|
||||||
|
active_only: bool = True,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
query = db.query(PlatformConfig)
|
||||||
|
if active_only:
|
||||||
|
query = query.filter(PlatformConfig.is_active == True)
|
||||||
|
return query.order_by(PlatformConfig.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=PlatformConfigResponse)
|
||||||
|
def create_platform(
|
||||||
|
data: PlatformConfigCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
existing = db.query(PlatformConfig).filter(PlatformConfig.platform == data.platform).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"平台 '{data.platform}' 已存在")
|
||||||
|
p = PlatformConfig(**data.model_dump())
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{platform}", response_model=PlatformConfigResponse)
|
||||||
|
def get_platform(platform: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
|
p = db.query(PlatformConfig).filter(PlatformConfig.platform == platform).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="平台不存在")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{platform}", response_model=PlatformConfigResponse)
|
||||||
|
def update_platform(platform: str, data: PlatformConfigUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
|
p = db.query(PlatformConfig).filter(PlatformConfig.platform == platform).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="平台不存在")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(p, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{platform}")
|
||||||
|
def delete_platform(platform: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
|
p = db.query(PlatformConfig).filter(PlatformConfig.platform == platform).first()
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(status_code=404, detail="平台不存在")
|
||||||
|
p.is_active = False
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..models import TopicField, TopicConfigField
|
||||||
|
from ..schemas import (
|
||||||
|
TopicFieldBase, TopicFieldResponse,
|
||||||
|
TopicConfigFieldBase, TopicConfigFieldResponse
|
||||||
|
)
|
||||||
|
from .auth import get_current_user
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/topic-config", tags=["topic-config"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fields", response_model=List[TopicFieldResponse])
|
||||||
|
def list_fields(
|
||||||
|
include_inactive: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
query = db.query(TopicField)
|
||||||
|
if not include_inactive:
|
||||||
|
query = query.filter(TopicField.is_active == True)
|
||||||
|
return query.order_by(TopicField.sort_order, TopicField.id).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fields", response_model=TopicFieldResponse)
|
||||||
|
def create_field(
|
||||||
|
data: TopicFieldBase,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
existing = db.query(TopicField).filter(TopicField.name == data.name).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"领域 '{data.name}' 已存在")
|
||||||
|
field = TopicField(**data.model_dump())
|
||||||
|
db.add(field)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(field)
|
||||||
|
return field
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/fields/{field_id}", response_model=TopicFieldResponse)
|
||||||
|
def update_field(
|
||||||
|
field_id: int,
|
||||||
|
data: TopicFieldBase,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||||
|
if not field:
|
||||||
|
raise HTTPException(status_code=404, detail="领域不存在")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(field, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(field)
|
||||||
|
return field
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/fields/{field_id}")
|
||||||
|
def delete_field(
|
||||||
|
field_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||||
|
if not field:
|
||||||
|
raise HTTPException(status_code=404, detail="领域不存在")
|
||||||
|
field.is_active = False
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "message": "领域已删除"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fields/{field_id}/scoring", response_model=List[TopicConfigFieldResponse])
|
||||||
|
def get_scoring_fields(
|
||||||
|
field_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||||
|
if not field:
|
||||||
|
raise HTTPException(status_code=404, detail="领域不存在")
|
||||||
|
return db.query(TopicConfigField).filter(
|
||||||
|
TopicConfigField.field_id == field_id
|
||||||
|
).order_by(TopicConfigField.sort_order).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fields/{field_id}/scoring", response_model=TopicConfigFieldResponse)
|
||||||
|
def create_scoring_field(
|
||||||
|
field_id: int,
|
||||||
|
data: TopicConfigFieldBase,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||||
|
if not field:
|
||||||
|
raise HTTPException(status_code=404, detail="领域不存在")
|
||||||
|
|
||||||
|
existing = db.query(TopicConfigField).filter(
|
||||||
|
TopicConfigField.field_id == field_id,
|
||||||
|
TopicConfigField.key == data.key
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail=f"字段 '{data.key}' 已存在")
|
||||||
|
|
||||||
|
config = TopicConfigField(field_id=field_id, **data.model_dump())
|
||||||
|
db.add(config)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/scoring/{config_id}", response_model=TopicConfigFieldResponse)
|
||||||
|
def update_scoring_field(
|
||||||
|
config_id: int,
|
||||||
|
data: TopicConfigFieldBase,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
cfg = db.query(TopicConfigField).filter(TopicConfigField.id == config_id).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="字段不存在")
|
||||||
|
for k, v in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(cfg, k, v)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/scoring/{config_id}")
|
||||||
|
def delete_scoring_field(
|
||||||
|
config_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
cfg = db.query(TopicConfigField).filter(TopicConfigField.id == config_id).first()
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="字段不存在")
|
||||||
|
db.delete(cfg)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/fields/{field_id}/scoring/batch", response_model=List[TopicConfigFieldResponse])
|
||||||
|
def batch_create_scoring_fields(
|
||||||
|
field_id: int,
|
||||||
|
fields: List[TopicConfigFieldBase],
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
field = db.query(TopicField).filter(TopicField.id == field_id).first()
|
||||||
|
if not field:
|
||||||
|
raise HTTPException(status_code=404, detail="领域不存在")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for f in fields:
|
||||||
|
existing = db.query(TopicConfigField).filter(
|
||||||
|
TopicConfigField.field_id == field_id,
|
||||||
|
TopicConfigField.key == f.key
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
for k, v in f.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(existing, k, v)
|
||||||
|
results.append(existing)
|
||||||
|
else:
|
||||||
|
obj = TopicConfigField(field_id=field_id, **f.model_dump())
|
||||||
|
db.add(obj)
|
||||||
|
results.append(obj)
|
||||||
|
db.commit()
|
||||||
|
for r in results:
|
||||||
|
db.refresh(r)
|
||||||
|
return results
|
||||||
+252
-136
@@ -1,189 +1,305 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from typing import List, Optional
|
from sqlalchemy import func
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Topic, PublishRecord
|
from ..models import Topic, TopicField, TopicConfigField, Article, PublishRecord, ContentMetrics
|
||||||
from ..schemas import TopicResponse, PublishRequest, PublishActionRequest, PublishRecordResponse
|
from ..schemas import (
|
||||||
|
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
|
||||||
|
PublishRequest, PublishActionRequest, PublishRecordResponse
|
||||||
|
)
|
||||||
from .auth import get_current_user
|
from .auth import get_current_user
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
|
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
|
||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[TopicResponse])
|
@router.get("", response_model=List[TopicResponse])
|
||||||
def list_topics(
|
def list_topics(
|
||||||
status: str = None,
|
field_id: Optional[int] = None,
|
||||||
|
status: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]),
|
||||||
|
order: str = Query("desc", enum=["asc", "desc"]),
|
||||||
|
limit: int = Query(50, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
# 确保读取最新数据,清除会话缓存
|
|
||||||
db.expire_all()
|
db.expire_all()
|
||||||
query = db.query(Topic)
|
query = db.query(Topic).options(joinedload(Topic.field))
|
||||||
|
|
||||||
|
if field_id:
|
||||||
|
query = query.filter(Topic.field_id == field_id)
|
||||||
if status:
|
if status:
|
||||||
query = query.filter(Topic.status == status)
|
query = query.filter(Topic.status == status)
|
||||||
topics = query.order_by(Topic.priority_score.desc(), Topic.created_at.desc()).all()
|
if tag:
|
||||||
return topics
|
query = query.filter(Topic.tags.contains([tag]))
|
||||||
|
if search:
|
||||||
|
query = query.filter(Topic.title.contains(search))
|
||||||
|
|
||||||
|
sort_col = getattr(Topic, sort_by, Topic.priority_score)
|
||||||
|
if order == "desc":
|
||||||
|
query = query.order_by(sort_col.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(sort_col.asc())
|
||||||
|
|
||||||
|
return query.offset(offset).limit(limit).all()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def topic_stats(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
total = db.query(Topic).count()
|
||||||
|
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
|
||||||
|
by_status = {s: c for s, c in raw}
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
|
||||||
|
|
||||||
|
published = db.query(Topic).filter(Topic.status == "published").count()
|
||||||
|
metrics_count = db.query(ContentMetrics).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"by_status": by_status,
|
||||||
|
"published": published,
|
||||||
|
"today_created": today_count,
|
||||||
|
"metrics_count": metrics_count
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TopicResponse)
|
||||||
|
def create_topic(
|
||||||
|
data: TopicCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
topic_id = data.id
|
||||||
|
if not topic_id:
|
||||||
|
max_topic = db.query(Topic).order_by(Topic.id.desc()).first()
|
||||||
|
if max_topic and max_topic.id.startswith("T"):
|
||||||
|
try:
|
||||||
|
num = int(max_topic.id[1:]) + 1
|
||||||
|
topic_id = f"T{num:03d}"
|
||||||
|
except:
|
||||||
|
topic_id = f"T{datetime.now().strftime('%m%d%H%M')}"
|
||||||
|
else:
|
||||||
|
topic_id = f"T{datetime.now().strftime('%m%d%H%M')}"
|
||||||
|
|
||||||
|
field_name = None
|
||||||
|
if data.field_id:
|
||||||
|
field = db.query(TopicField).filter(TopicField.id == data.field_id).first()
|
||||||
|
if field:
|
||||||
|
field_name = field.name
|
||||||
|
|
||||||
|
topic = Topic(
|
||||||
|
id=topic_id,
|
||||||
|
field_id=data.field_id,
|
||||||
|
field_name=field_name,
|
||||||
|
title=data.title,
|
||||||
|
format=data.format,
|
||||||
|
core_concept=data.core_concept,
|
||||||
|
audience_pain=data.audience_pain,
|
||||||
|
unique_angle=data.unique_angle,
|
||||||
|
priority=data.priority,
|
||||||
|
status="pending",
|
||||||
|
tags=data.tags or [],
|
||||||
|
custom_data=data.custom_data or {},
|
||||||
|
scoring_data=data.scoring_data or {},
|
||||||
|
)
|
||||||
|
db.add(topic)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(topic)
|
||||||
|
return topic
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{topic_id}", response_model=TopicResponse)
|
@router.get("/{topic_id}", response_model=TopicResponse)
|
||||||
def get_topic(topic_id: str, db: Session = Depends(get_db)):
|
def get_topic(topic_id: str, db: Session = Depends(get_db)):
|
||||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
topic = db.query(Topic).options(joinedload(Topic.field)).filter(Topic.id == topic_id).first()
|
||||||
if not topic:
|
if not topic:
|
||||||
raise HTTPException(status_code=404, detail="Topic not found")
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
return topic
|
return topic
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{topic_id}", response_model=TopicResponse)
|
||||||
|
def update_topic(
|
||||||
|
topic_id: str,
|
||||||
|
data: TopicUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
|
|
||||||
|
if data.field_id is not None:
|
||||||
|
topic.field_id = data.field_id
|
||||||
|
if data.field_id:
|
||||||
|
field = db.query(TopicField).filter(TopicField.id == data.field_id).first()
|
||||||
|
topic.field_name = field.name if field else None
|
||||||
|
|
||||||
|
for k, v in data.model_dump(exclude_unset=True, exclude={"field_id"}).items():
|
||||||
|
if k == "tags" or k == "custom_data" or k == "scoring_data":
|
||||||
|
if v is not None:
|
||||||
|
setattr(topic, k, v)
|
||||||
|
elif v is not None:
|
||||||
|
setattr(topic, k, v)
|
||||||
|
|
||||||
|
topic.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(topic)
|
||||||
|
return topic
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{topic_id}")
|
||||||
|
def delete_topic(
|
||||||
|
topic_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
|
db.delete(topic)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{topic_id}/score")
|
||||||
|
def score_topic(
|
||||||
|
topic_id: str,
|
||||||
|
data: TopicScoreRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
|
|
||||||
|
topic.scoring_data = data.scoring_data
|
||||||
|
|
||||||
|
if data.scoring_data and topic.field_id:
|
||||||
|
configs = db.query(TopicConfigField).filter(
|
||||||
|
TopicConfigField.field_id == topic.field_id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total_weight = 0
|
||||||
|
weighted_sum = 0
|
||||||
|
for cfg in configs:
|
||||||
|
val = data.scoring_data.get(cfg.key)
|
||||||
|
if val is not None and cfg.field_type == "number":
|
||||||
|
if cfg.min_value is not None:
|
||||||
|
val = max(val, cfg.min_value)
|
||||||
|
if cfg.max_value is not None:
|
||||||
|
val = min(val, cfg.max_value)
|
||||||
|
normalized = (val - cfg.min_value) / (cfg.max_value - cfg.min_value) if cfg.max_value != cfg.min_value else 0.5
|
||||||
|
weighted_sum += normalized * cfg.weight
|
||||||
|
total_weight += cfg.weight
|
||||||
|
|
||||||
|
if total_weight > 0:
|
||||||
|
topic.total_score = round(weighted_sum / total_weight * 100, 1)
|
||||||
|
topic.priority_score = int(topic.total_score)
|
||||||
|
|
||||||
|
topic.updated_at = datetime.now()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(topic)
|
||||||
|
return {"priority_score": topic.priority_score, "total_score": topic.total_score}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{topic_id}/publish")
|
@router.post("/{topic_id}/publish")
|
||||||
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
|
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
|
||||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
if not topic:
|
if not topic:
|
||||||
raise HTTPException(status_code=404, detail="Topic not found")
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
if topic.status != "ready":
|
if topic.status not in ("pending", "ready", "draft"):
|
||||||
raise HTTPException(status_code=400, detail="Topic not in ready status")
|
raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布")
|
||||||
|
|
||||||
# 更新选题状态
|
|
||||||
topic.status = "published"
|
topic.status = "published"
|
||||||
topic.published_at = datetime.now().date()
|
topic.published_at = date.today()
|
||||||
topic.updated_at = datetime.now()
|
topic.updated_at = datetime.now()
|
||||||
topic.platform_urls = req.platform_urls
|
topic.platform_urls = req.platform_urls
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# 创建发布记录
|
|
||||||
record = PublishRecord(
|
record = PublishRecord(
|
||||||
topic_id=topic_id,
|
topic_id=topic_id,
|
||||||
platform=req.platform,
|
platform="all",
|
||||||
action="publish",
|
action="publish",
|
||||||
status="success",
|
status="success",
|
||||||
operator=req.operator,
|
description=f"选题 {topic_id} 已发布"
|
||||||
description=req.description,
|
|
||||||
suggestion=req.suggestion,
|
|
||||||
url=req.url,
|
|
||||||
error_msg=req.error_msg
|
|
||||||
)
|
)
|
||||||
db.add(record)
|
db.add(record)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(record)
|
return {"ok": True, "topic_id": topic_id}
|
||||||
|
|
||||||
return {"message": "Topic marked as published", "topic_id": topic_id, "record_id": record.id}
|
|
||||||
|
|
||||||
|
|
||||||
# 获取选题的发布记录
|
@router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]])
|
||||||
@router.get("/{topic_id}/publish-records", response_model=List[PublishRecordResponse])
|
def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
|
||||||
def get_publish_records(topic_id: str, db: Session = Depends(get_db)):
|
|
||||||
records = db.query(PublishRecord).filter(PublishRecord.topic_id == topic_id).order_by(PublishRecord.created_at.desc()).all()
|
|
||||||
return records
|
|
||||||
|
|
||||||
|
|
||||||
# 创建新的发布记录(用于手动记录发布情况)
|
|
||||||
@router.post("/{topic_id}/publish-records")
|
|
||||||
def create_publish_record(topic_id: str, req: PublishActionRequest, db: Session = Depends(get_db)):
|
|
||||||
# 验证选题存在
|
|
||||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
if not topic:
|
if not topic:
|
||||||
raise HTTPException(status_code=404, detail="Topic not found")
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
|
articles = db.query(Article).filter(Article.topic_id == topic_id).all()
|
||||||
record = PublishRecord(
|
return [a.to_dict() if hasattr(a, 'to_dict') else {
|
||||||
topic_id=topic_id,
|
"id": a.id, "topic_id": a.topic_id, "platform": a.platform,
|
||||||
platform=req.platform or "unknown",
|
"status": a.status, "file_path": a.file_path
|
||||||
action=req.action,
|
} for a in articles]
|
||||||
status=req.status,
|
|
||||||
operator=req.operator,
|
|
||||||
description=req.description,
|
|
||||||
suggestion=req.suggestion,
|
|
||||||
url=req.url,
|
|
||||||
error_msg=req.error_msg
|
|
||||||
)
|
|
||||||
db.add(record)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(record)
|
|
||||||
|
|
||||||
# 如果操作是发布成功,且platform指定,则更新topic的platform_urls
|
|
||||||
if req.action == "publish" and req.status == "success" and req.platform and req.url:
|
|
||||||
if not topic.platform_urls:
|
|
||||||
topic.platform_urls = {}
|
|
||||||
topic.platform_urls[req.platform] = req.url
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
# 更新发布记录
|
@router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]])
|
||||||
@router.put("/publish-records/{record_id}")
|
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)):
|
||||||
def update_publish_record(record_id: int, req: PublishActionRequest, db: Session = Depends(get_db)):
|
metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all()
|
||||||
record = db.query(PublishRecord).filter(PublishRecord.id == record_id).first()
|
return [m.to_dict() for m in metrics]
|
||||||
if not record:
|
|
||||||
raise HTTPException(status_code=404, detail="Record not found")
|
|
||||||
|
|
||||||
# 更新字段
|
|
||||||
for field, value in req.dict(exclude_unset=True).items():
|
|
||||||
setattr(record, field, value)
|
|
||||||
record.updated_at = datetime.now()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return record
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{topic_id}/preview")
|
@router.post("/{topic_id}/lock")
|
||||||
def preview_topic(topic_id: str, platform: str = Query("zhihu", regex="^(zhihu|wechat|xiaohongshu)$")):
|
def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
"""
|
|
||||||
预览某选题在指定平台的HTML内容。
|
|
||||||
查找最近发布的release文件。
|
|
||||||
"""
|
|
||||||
# 查找最近的发布包
|
|
||||||
releases_dir = PROJECT_ROOT / "automation" / "data" / "releases"
|
|
||||||
if not releases_dir.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="No releases found")
|
|
||||||
|
|
||||||
# 按日期倒序查找
|
|
||||||
dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True)
|
|
||||||
found = None
|
|
||||||
for dt in dates:
|
|
||||||
file_path = releases_dir / dt / platform / f"{platform}_{topic_id}_{platform}.html"
|
|
||||||
if file_path.exists():
|
|
||||||
found = file_path
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Preview not found for topic {topic_id} on {platform}")
|
|
||||||
|
|
||||||
content = found.read_text(encoding="utf-8")
|
|
||||||
return {"topic_id": topic_id, "platform": platform, "html": content}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{topic_id}/packages")
|
|
||||||
def list_packages(topic_id: str):
|
|
||||||
"""
|
|
||||||
列出某选题的所有发布包(HTML文件)。
|
|
||||||
"""
|
|
||||||
releases_dir = PROJECT_ROOT / "automation" / "data" / "releases"
|
|
||||||
if not releases_dir.exists():
|
|
||||||
return {"packages": []}
|
|
||||||
|
|
||||||
packages = []
|
|
||||||
dates = sorted([d.name for d in releases_dir.iterdir() if d.is_dir()], reverse=True)
|
|
||||||
for dt in dates:
|
|
||||||
date_dir = releases_dir / dt
|
|
||||||
for platform in ["zhihu", "wechat", "xiaohongshu"]:
|
|
||||||
file_path = date_dir / platform / f"{platform}_{topic_id}_{platform}.html"
|
|
||||||
if file_path.exists():
|
|
||||||
stat = file_path.stat()
|
|
||||||
packages.append({
|
|
||||||
"platform": platform,
|
|
||||||
"path": str(file_path.relative_to(PROJECT_ROOT)),
|
|
||||||
"size": stat.st_size,
|
|
||||||
"modified": datetime.fromtimestamp(stat.st_mtime).isoformat()
|
|
||||||
})
|
|
||||||
|
|
||||||
return {"packages": packages}
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{topic_id}")
|
|
||||||
def delete_topic(topic_id: str, db: Session = Depends(get_db)):
|
|
||||||
"""删除选题"""
|
|
||||||
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
if not topic:
|
if not topic:
|
||||||
raise HTTPException(status_code=404, detail="选题不存在")
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
|
topic.lock_by = current_user.username
|
||||||
db.delete(topic)
|
topic.lock_at = datetime.now()
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "删除成功", "topic_id": topic_id}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{topic_id}/unlock")
|
||||||
|
def unlock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
|
||||||
|
topic = db.query(Topic).filter(Topic.id == topic_id).first()
|
||||||
|
if not topic:
|
||||||
|
raise HTTPException(status_code=404, detail="Topic not found")
|
||||||
|
topic.lock_by = None
|
||||||
|
topic.lock_at = None
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/batch-update-status")
|
||||||
|
def batch_update_status(
|
||||||
|
topic_ids: List[str],
|
||||||
|
status: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
updated = db.query(Topic).filter(Topic.id.in_(topic_ids)).update(
|
||||||
|
{Topic.status: status, Topic.updated_at: datetime.now()},
|
||||||
|
synchronize_session=False
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "updated": updated}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/field-distribution")
|
||||||
|
def field_distribution(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
rows = db.query(
|
||||||
|
Topic.field_name,
|
||||||
|
func.count(Topic.id).label("count")
|
||||||
|
).group_by(Topic.field_name).all()
|
||||||
|
return [{"field": r.field_name or "未分类", "count": r.count} for r in rows]
|
||||||
@@ -4,14 +4,11 @@ from sqlalchemy.orm import sessionmaker
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran)
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||||
|
|
||||||
# 数据库配置:通过环境变量控制
|
USE_POSTGRES = os.getenv('USE_POSTGRES', 'true').lower() == 'true'
|
||||||
USE_POSTGRES = os.getenv('USE_POSTGRES', 'false').lower() == 'true'
|
|
||||||
|
|
||||||
if USE_POSTGRES:
|
if USE_POSTGRES:
|
||||||
# PostgreSQL 生产数据库
|
|
||||||
POSTGRES_CONFIG = {
|
POSTGRES_CONFIG = {
|
||||||
'host': os.getenv('PG_HOST', '127.0.0.1'),
|
'host': os.getenv('PG_HOST', '127.0.0.1'),
|
||||||
'port': os.getenv('PG_PORT', '5432'),
|
'port': os.getenv('PG_PORT', '5432'),
|
||||||
@@ -19,10 +16,17 @@ if USE_POSTGRES:
|
|||||||
'user': os.getenv('PG_USER', 'yzr_nr'),
|
'user': os.getenv('PG_USER', 'yzr_nr'),
|
||||||
'password': os.getenv('PG_PASSWORD', 'aTX3WKKnPfRnM5PC')
|
'password': os.getenv('PG_PASSWORD', 'aTX3WKKnPfRnM5PC')
|
||||||
}
|
}
|
||||||
SQLALCHEMY_DATABASE_URL = f"postgresql://{POSTGRES_CONFIG['user']}:{POSTGRES_CONFIG['password']}@{POSTGRES_CONFIG['host']}:{POSTGRES_CONFIG['port']}/{POSTGRES_CONFIG['database']}"
|
SQLALCHEMY_DATABASE_URL = (
|
||||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
f"postgresql://{POSTGRES_CONFIG['user']}:{POSTGRES_CONFIG['password']}"
|
||||||
|
f"@{POSTGRES_CONFIG['host']}:{POSTGRES_CONFIG['port']}/{POSTGRES_CONFIG['database']}"
|
||||||
|
)
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# SQLite 开发数据库
|
|
||||||
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
|
DATA_DIR = os.getenv('DATA_DIR', str(PROJECT_ROOT / 'data'))
|
||||||
os.makedirs(DATA_DIR, exist_ok=True)
|
os.makedirs(DATA_DIR, exist_ok=True)
|
||||||
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
|
DB_PATH = os.path.join(DATA_DIR, 'yzr.db')
|
||||||
|
|||||||
@@ -3,85 +3,36 @@ import os
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .database import SessionLocal, init_db
|
from .database import SessionLocal, init_db
|
||||||
from .models import Topic, User, Case, LLMConfig, SystemConfig
|
from .models import (
|
||||||
|
Topic, TopicField, TopicConfigField, TopicStatusConfig,
|
||||||
|
User, Case, LLMConfig, SystemConfig, PlatformConfig
|
||||||
|
)
|
||||||
import bcrypt
|
import bcrypt
|
||||||
|
|
||||||
# 计算项目根目录(backend/app/initial_data.py -> 上升3层到 yu-zhi-ran)
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
if os.getenv('PROJECT_ROOT'):
|
if os.getenv('PROJECT_ROOT'):
|
||||||
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||||
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
TOPICS_FILE = PROJECT_ROOT / "automation" / "data" / "sustainability_topics.json"
|
||||||
CASES_FILE = PROJECT_ROOT / "automation" / "data" / "initial_cases.json"
|
CASES_FILE = PROJECT_ROOT / "automation" / "data" / "initial_cases.json"
|
||||||
|
|
||||||
# 从环境变量读取管理员配置
|
|
||||||
DEFAULT_ADMIN_USERNAME = os.getenv('DEFAULT_ADMIN_USERNAME', 'admin')
|
DEFAULT_ADMIN_USERNAME = os.getenv('DEFAULT_ADMIN_USERNAME', 'admin')
|
||||||
DEFAULT_ADMIN_PASSWORD = os.getenv('DEFAULT_ADMIN_PASSWORD', 'admin123')
|
DEFAULT_ADMIN_PASSWORD = os.getenv('DEFAULT_ADMIN_PASSWORD', 'admin123')
|
||||||
|
|
||||||
|
|
||||||
def import_initial_data():
|
def import_initial_data():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
# 1. 导入选题数据
|
if db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first() is None:
|
||||||
if db.query(Topic).count() == 0:
|
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
|
||||||
if __import__('os').path.exists(TOPICS_FILE):
|
admin = User(
|
||||||
topics = json.loads(open(TOPICS_FILE, encoding='utf-8').read())
|
username=DEFAULT_ADMIN_USERNAME,
|
||||||
# 去重:保留每个 ID 最后出现的记录
|
password_hash=hashed.decode('utf-8'),
|
||||||
seen = {}
|
role="admin"
|
||||||
for t in topics:
|
|
||||||
seen[t['id']] = t
|
|
||||||
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.add(admin)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"✅ 导入 {len(unique_topics)} 个选题到数据库(去重后)")
|
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
|
||||||
else:
|
|
||||||
print(f"⚠️ 选题文件不存在: {TOPICS_FILE}")
|
|
||||||
else:
|
|
||||||
print("数据库已有选题数据,跳过导入")
|
|
||||||
|
|
||||||
|
|
||||||
# 3. 导入案例数据(如果为空)
|
|
||||||
if db.query(Case).count() == 0:
|
|
||||||
if __import__('os').path.exists(CASES_FILE):
|
|
||||||
cases_data = json.loads(open(CASES_FILE, encoding='utf-8').read())
|
|
||||||
for c in cases_data:
|
|
||||||
case = Case(
|
|
||||||
id=c['id'],
|
|
||||||
title=c['title'],
|
|
||||||
field=c['field'],
|
|
||||||
summary=c['summary'],
|
|
||||||
key_metrics=c.get('key_metrics'),
|
|
||||||
date=c.get('date'),
|
|
||||||
source=c['source'],
|
|
||||||
source_url=c.get('source_url'),
|
|
||||||
credibility_rating=c.get('credibility_rating'),
|
|
||||||
china_applicability=c.get('china_applicability')
|
|
||||||
)
|
|
||||||
db.add(case)
|
|
||||||
db.commit()
|
|
||||||
print(f"✅ 导入 {len(cases_data)} 条案例")
|
|
||||||
else:
|
|
||||||
print(f"⚠️ 案例文件不存在: {CASES_FILE}")
|
|
||||||
|
|
||||||
# 4. 插入 LLM 默认配置
|
|
||||||
if db.query(LLMConfig).count() == 0:
|
if db.query(LLMConfig).count() == 0:
|
||||||
default_llm = LLMConfig(
|
default_llm = LLMConfig(
|
||||||
name="default_expand",
|
name="default_expand",
|
||||||
@@ -117,7 +68,6 @@ def import_initial_data():
|
|||||||
db.commit()
|
db.commit()
|
||||||
print("✅ 插入默认 LLM 配置")
|
print("✅ 插入默认 LLM 配置")
|
||||||
|
|
||||||
# 5. 插入系统配置默认值
|
|
||||||
default_system_configs = [
|
default_system_configs = [
|
||||||
{"key": "collector_enabled", "value": "false", "description": "是否启用采集器"},
|
{"key": "collector_enabled", "value": "false", "description": "是否启用采集器"},
|
||||||
{"key": "scheduler_interval", "value": "daily", "description": "调度间隔:daily/hourly/weekly"},
|
{"key": "scheduler_interval", "value": "daily", "description": "调度间隔:daily/hourly/weekly"},
|
||||||
@@ -128,32 +78,148 @@ def import_initial_data():
|
|||||||
db.commit()
|
db.commit()
|
||||||
print("✅ 插入默认系统配置")
|
print("✅ 插入默认系统配置")
|
||||||
|
|
||||||
# 2. 创建默认管理员用户(bcrypt 哈希)
|
if db.query(PlatformConfig).count() == 0:
|
||||||
admin_exists = db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first()
|
platforms = [
|
||||||
if not admin_exists:
|
{
|
||||||
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
|
"platform": "zhihu",
|
||||||
admin = User(
|
"name": "知乎",
|
||||||
username=DEFAULT_ADMIN_USERNAME,
|
"icon": "🔍",
|
||||||
password_hash=hashed.decode('utf-8'),
|
"default_format": "长文深度分析,1500-3000字,有数据支撑",
|
||||||
role="admin"
|
"compliance_rules": {
|
||||||
|
"max_length": 50000,
|
||||||
|
"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("✅ 插入平台配置")
|
||||||
|
|
||||||
|
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(admin)
|
db.add(topic)
|
||||||
db.commit()
|
db.commit()
|
||||||
print(f"✅ 创建默认管理员: {DEFAULT_ADMIN_USERNAME}")
|
print(f"✅ 导入 {len(unique_topics)} 个选题")
|
||||||
else:
|
else:
|
||||||
# 如果管理员已存在但密码为空,更新为默认密码的哈希
|
print(f"⚠️ 选题文件不存在: {TOPICS_FILE}")
|
||||||
if not admin_exists.password_hash:
|
|
||||||
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
|
if db.query(Case).count() == 0:
|
||||||
admin_exists.password_hash = hashed.decode('utf-8')
|
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()
|
db.commit()
|
||||||
print(f"✅ 更新管理员密码")
|
print(f"✅ 导入 {len(cases_data)} 条案例")
|
||||||
print(f"管理员已存在: {DEFAULT_ADMIN_USERNAME}")
|
|
||||||
|
print("✅ 初始化完成")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
print(f"初始化失败: {e}")
|
print(f"初始化失败: {e}")
|
||||||
db.rollback()
|
db.rollback()
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
init_db()
|
init_db()
|
||||||
import_initial_data()
|
import_initial_data()
|
||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from .database import engine, get_db, init_db
|
from .database import engine, get_db, init_db
|
||||||
from .models import Base
|
from .models import Base
|
||||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs
|
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config
|
||||||
from .initial_data import import_initial_data
|
from .initial_data import import_initial_data
|
||||||
|
|
||||||
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
|
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
|
||||||
@@ -42,6 +42,12 @@ app.include_router(cases.router)
|
|||||||
app.include_router(task_logs.router)
|
app.include_router(task_logs.router)
|
||||||
app.include_router(llm_configs.router)
|
app.include_router(llm_configs.router)
|
||||||
app.include_router(system_configs.router)
|
app.include_router(system_configs.router)
|
||||||
|
app.include_router(topic_config.router)
|
||||||
|
app.include_router(calendar.router)
|
||||||
|
app.include_router(metrics.router)
|
||||||
|
app.include_router(assets.router)
|
||||||
|
app.include_router(tasks.router)
|
||||||
|
app.include_router(platform_config.router)
|
||||||
|
|
||||||
# 挂载前端
|
# 挂载前端
|
||||||
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
|
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
|
||||||
|
|||||||
+346
-47
@@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON
|
from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
from .database import Base
|
from .database import Base
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -8,14 +9,14 @@ class AuditLog(Base):
|
|||||||
__tablename__ = "audit_logs"
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
user_id = Column(Integer, nullable=True, index=True) # 操作用户ID(未登录/匿名可为空)
|
user_id = Column(Integer, nullable=True, index=True)
|
||||||
username = Column(String, nullable=False) # 操作用户名(冗余存储)
|
username = Column(String, nullable=False)
|
||||||
action = Column(String, nullable=False, index=True) # 操作类型: login/logout/create_user/update_user/delete_user/publish/etc.
|
action = Column(String, nullable=False, index=True)
|
||||||
resource_type = Column(String, nullable=True, index=True) # 资源类型: user/topic/publish_record/etc.
|
resource_type = Column(String, nullable=True, index=True)
|
||||||
resource_id = Column(String, nullable=True) # 资源ID
|
resource_id = Column(String, nullable=True)
|
||||||
details = Column(JSON, default=dict, nullable=True) # 操作详情(变更前后、额外信息等)
|
details = Column(JSON, default=dict, nullable=True)
|
||||||
ip_address = Column(String, nullable=True) # IP 地址
|
ip_address = Column(String, nullable=True)
|
||||||
user_agent = Column(String, nullable=True) # User-Agent
|
user_agent = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
@@ -38,8 +39,8 @@ class User(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
username = Column(String, unique=True, nullable=False, index=True)
|
username = Column(String, unique=True, nullable=False, index=True)
|
||||||
password_hash = Column(String, nullable=False) # bcrypt 哈希
|
password_hash = Column(String, nullable=False)
|
||||||
role = Column(String, default="user", nullable=False) # admin/user
|
role = Column(String, default="user", nullable=False)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
@@ -52,62 +53,363 @@ class User(Base):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TopicField(Base):
|
||||||
|
__tablename__ = "topic_fields"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
icon = Column(String, nullable=True)
|
||||||
|
color = Column(String, nullable=True)
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
parent_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
parent = relationship("TopicField", remote_side=[id], backref="children")
|
||||||
|
scoring_fields = relationship("TopicConfigField", back_populates="field", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"icon": self.icon,
|
||||||
|
"color": self.color,
|
||||||
|
"description": self.description,
|
||||||
|
"parent_id": self.parent_id,
|
||||||
|
"sort_order": self.sort_order,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TopicConfigField(Base):
|
||||||
|
__tablename__ = "topic_config_fields"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=False)
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
key = Column(String, nullable=False)
|
||||||
|
field_type = Column(String, default="number") # number/select/multi_select/text
|
||||||
|
weight = Column(Float, default=1.0)
|
||||||
|
options = Column(JSON, default=list) # for select/multi_select
|
||||||
|
min_value = Column(Float, nullable=True)
|
||||||
|
max_value = Column(Float, nullable=True)
|
||||||
|
is_required = Column(Boolean, default=False)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
field = relationship("TopicField", back_populates="scoring_fields")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"field_id": self.field_id,
|
||||||
|
"name": self.name,
|
||||||
|
"key": self.key,
|
||||||
|
"field_type": self.field_type,
|
||||||
|
"weight": self.weight,
|
||||||
|
"options": self.options or [],
|
||||||
|
"min_value": self.min_value,
|
||||||
|
"max_value": self.max_value,
|
||||||
|
"is_required": self.is_required,
|
||||||
|
"sort_order": self.sort_order,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TopicStatusConfig(Base):
|
||||||
|
__tablename__ = "topic_status_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
status = Column(String, unique=True, nullable=False)
|
||||||
|
label = Column(String, nullable=False)
|
||||||
|
color = Column(String, nullable=True)
|
||||||
|
icon = Column(String, nullable=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
is_default = Column(Boolean, default=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"status": self.status,
|
||||||
|
"label": self.label,
|
||||||
|
"color": self.color,
|
||||||
|
"icon": self.icon,
|
||||||
|
"sort_order": self.sort_order,
|
||||||
|
"is_default": self.is_default,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Topic(Base):
|
class Topic(Base):
|
||||||
__tablename__ = "topics"
|
__tablename__ = "topics"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True)
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
|
||||||
|
field_name = Column(String, nullable=True)
|
||||||
title = Column(String, nullable=False)
|
title = Column(String, nullable=False)
|
||||||
field = Column(String, nullable=False)
|
|
||||||
format = Column(String)
|
format = Column(String)
|
||||||
core_concept = Column(Text)
|
core_concept = Column(Text)
|
||||||
audience_pain = Column(Text)
|
audience_pain = Column(Text)
|
||||||
unique_angle = Column(Text)
|
unique_angle = Column(Text)
|
||||||
priority = Column(String) # 高/中
|
priority = Column(String)
|
||||||
priority_score = Column(Integer, default=0)
|
priority_score = Column(Integer, default=0)
|
||||||
total_score = Column(Float)
|
total_score = Column(Float)
|
||||||
status = Column(String, default="pending") # pending/draft/ready/published
|
status = Column(String, default="pending")
|
||||||
cases = Column(JSON, default=list)
|
cases = Column(JSON, default=list)
|
||||||
source_file = Column(String)
|
source_file = Column(String)
|
||||||
|
tags = Column(JSON, default=list)
|
||||||
|
custom_data = Column(JSON, default=dict)
|
||||||
|
scoring_data = Column(JSON, default=dict)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
generated_at = Column(DateTime(timezone=True), nullable=True) # 选题创作完成时间
|
generated_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
ready_at = Column(Date)
|
ready_at = Column(Date)
|
||||||
published_at = Column(Date)
|
published_at = Column(Date)
|
||||||
compliance_score = Column(Integer)
|
compliance_score = Column(Integer)
|
||||||
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
|
platform_urls = Column(JSON, default=dict)
|
||||||
|
lock_by = Column(String, nullable=True)
|
||||||
|
lock_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
field = relationship("TopicField", backref="topics")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def field_info(self):
|
||||||
|
if self.field:
|
||||||
|
return self.field.to_dict()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
class Article(Base):
|
class Article(Base):
|
||||||
__tablename__ = "articles"
|
__tablename__ = "articles"
|
||||||
|
|
||||||
id = Column(String, primary_key=True) # e.g., A01_zhihu
|
id = Column(String, primary_key=True)
|
||||||
topic_id = Column(String, nullable=False)
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
|
||||||
platform = Column(String, nullable=False)
|
platform = Column(String, nullable=False)
|
||||||
file_path = Column(String, nullable=False)
|
file_path = Column(String, nullable=False)
|
||||||
status = Column(String, default="draft") # draft/optimized/published
|
status = Column(String, default="draft")
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
compliance_score = Column(Integer)
|
compliance_score = Column(Integer)
|
||||||
html_content = Column(Text) # 可缓存HTML内容以便预览
|
html_content = Column(Text)
|
||||||
|
word_count = Column(Integer, nullable=True)
|
||||||
|
outline = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class PublishRecord(Base):
|
class PublishRecord(Base):
|
||||||
__tablename__ = "publish_records"
|
__tablename__ = "publish_records"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
topic_id = Column(String, nullable=False)
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
|
||||||
platform = Column(String, nullable=False) # 发布平台:zhihu/wechat/xiaohongshu
|
platform = Column(String, nullable=False)
|
||||||
action = Column(String, nullable=False) # 操作:publish/update/delete/invalid
|
action = Column(String, nullable=False)
|
||||||
status = Column(String, nullable=False) # 状态:success/failed/partial/cancelled
|
status = Column(String, nullable=False)
|
||||||
operator = Column(String, nullable=True) # 操作人
|
operator = Column(String, nullable=True)
|
||||||
description = Column(Text, nullable=True) # 发布说明
|
description = Column(Text, nullable=True)
|
||||||
suggestion = Column(Text, nullable=True) # 建议内容
|
suggestion = Column(Text, nullable=True)
|
||||||
url = Column(String, nullable=True) # 发布后链接
|
url = Column(String, nullable=True)
|
||||||
error_msg = Column(Text, nullable=True) # 错误信息
|
error_msg = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# 关联查询
|
topic = relationship("Topic")
|
||||||
# 可以添加外键关联到 Topic, 但这里保持简单
|
|
||||||
|
|
||||||
|
class ContentCalendar(Base):
|
||||||
|
__tablename__ = "content_calendar"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
|
||||||
|
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
|
||||||
|
title = Column(String, nullable=False)
|
||||||
|
planned_date = Column(Date, nullable=False, index=True)
|
||||||
|
published_date = Column(Date, nullable=True)
|
||||||
|
platform = Column(String, nullable=True)
|
||||||
|
status = Column(String, default="planned") # planned/published/delayed/cancelled
|
||||||
|
reminder_time = Column(DateTime, nullable=True)
|
||||||
|
notes = Column(Text, nullable=True)
|
||||||
|
created_by = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
topic = relationship("Topic")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"topic_id": self.topic_id,
|
||||||
|
"field_id": self.field_id,
|
||||||
|
"title": self.title,
|
||||||
|
"planned_date": self.planned_date.isoformat() if self.planned_date else None,
|
||||||
|
"published_date": self.published_date.isoformat() if self.published_date else None,
|
||||||
|
"platform": self.platform,
|
||||||
|
"status": self.status,
|
||||||
|
"reminder_time": self.reminder_time.isoformat() if self.reminder_time else None,
|
||||||
|
"notes": self.notes,
|
||||||
|
"created_by": self.created_by,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ContentMetrics(Base):
|
||||||
|
__tablename__ = "content_metrics"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=False, index=True)
|
||||||
|
platform = Column(String, nullable=False)
|
||||||
|
publish_url = Column(String, nullable=True)
|
||||||
|
views = Column(Integer, default=0)
|
||||||
|
likes = Column(Integer, default=0)
|
||||||
|
favorites = Column(Integer, default=0)
|
||||||
|
comments = Column(Integer, default=0)
|
||||||
|
shares = Column(Integer, default=0)
|
||||||
|
last_fetched = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
data_snapshot = Column(JSON, default=dict)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
topic = relationship("Topic")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def engagement_rate(self):
|
||||||
|
total = self.views or 0
|
||||||
|
if total == 0:
|
||||||
|
return 0
|
||||||
|
return round((self.likes or 0) / total * 100, 2)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"topic_id": self.topic_id,
|
||||||
|
"platform": self.platform,
|
||||||
|
"publish_url": self.publish_url,
|
||||||
|
"views": self.views,
|
||||||
|
"likes": self.likes,
|
||||||
|
"favorites": self.favorites,
|
||||||
|
"comments": self.comments,
|
||||||
|
"shares": self.shares,
|
||||||
|
"engagement_rate": self.engagement_rate,
|
||||||
|
"last_fetched": self.last_fetched.isoformat() if self.last_fetched else None,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MediaAsset(Base):
|
||||||
|
__tablename__ = "media_assets"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
filename = Column(String, nullable=False)
|
||||||
|
file_path = Column(String, nullable=False)
|
||||||
|
file_url = Column(String, nullable=True)
|
||||||
|
file_type = Column(String, nullable=False) # image/video/document
|
||||||
|
mime_type = Column(String, nullable=True)
|
||||||
|
size = Column(Integer, nullable=True)
|
||||||
|
width = Column(Integer, nullable=True)
|
||||||
|
height = Column(Integer, nullable=True)
|
||||||
|
thumbnail_path = Column(String, nullable=True)
|
||||||
|
alt_text = Column(String, nullable=True)
|
||||||
|
tags = Column(JSON, default=list)
|
||||||
|
topic_ids = Column(JSON, default=list)
|
||||||
|
usage_count = Column(Integer, default=0)
|
||||||
|
uploaded_by = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"filename": self.filename,
|
||||||
|
"file_path": self.file_path,
|
||||||
|
"file_url": self.file_url,
|
||||||
|
"file_type": self.file_type,
|
||||||
|
"mime_type": self.mime_type,
|
||||||
|
"size": self.size,
|
||||||
|
"width": self.width,
|
||||||
|
"height": self.height,
|
||||||
|
"thumbnail_path": self.thumbnail_path,
|
||||||
|
"alt_text": self.alt_text,
|
||||||
|
"tags": self.tags or [],
|
||||||
|
"topic_ids": self.topic_ids or [],
|
||||||
|
"usage_count": self.usage_count,
|
||||||
|
"uploaded_by": self.uploaded_by,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformConfig(Base):
|
||||||
|
__tablename__ = "platform_configs"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
platform = Column(String, unique=True, nullable=False) # zhihu/wechat/xiaohongshu
|
||||||
|
name = Column(String, nullable=False)
|
||||||
|
icon = Column(String, nullable=True)
|
||||||
|
api_endpoint = Column(String, nullable=True)
|
||||||
|
auth_config = Column(JSON, default=dict)
|
||||||
|
format_template = Column(JSON, default=dict)
|
||||||
|
compliance_rules = Column(JSON, default=dict)
|
||||||
|
default_format = Column(Text, nullable=True)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"platform": self.platform,
|
||||||
|
"name": self.name,
|
||||||
|
"icon": self.icon,
|
||||||
|
"api_endpoint": self.api_endpoint,
|
||||||
|
"format_template": self.format_template or {},
|
||||||
|
"compliance_rules": self.compliance_rules or {},
|
||||||
|
"default_format": self.default_format,
|
||||||
|
"is_active": self.is_active,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ContentTask(Base):
|
||||||
|
__tablename__ = "content_tasks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
|
topic_id = Column(String, ForeignKey("topics.id"), nullable=True, index=True)
|
||||||
|
task_id = Column(String, unique=True, nullable=False)
|
||||||
|
stage = Column(String, nullable=False) # research/outline/writer/optimizer/format/publish
|
||||||
|
status = Column(String, default="pending") # pending/running/completed/failed/cancelled
|
||||||
|
progress = Column(Integer, default=0)
|
||||||
|
message = Column(Text, nullable=True)
|
||||||
|
result_data = Column(JSON, default=dict)
|
||||||
|
error_msg = Column(Text, nullable=True)
|
||||||
|
started_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
duration = Column(Integer, nullable=True)
|
||||||
|
created_by = Column(String, nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
topic = relationship("Topic")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"topic_id": self.topic_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"stage": self.stage,
|
||||||
|
"status": self.status,
|
||||||
|
"progress": self.progress,
|
||||||
|
"message": self.message,
|
||||||
|
"error_msg": self.error_msg,
|
||||||
|
"started_at": self.started_at.isoformat() if self.started_at else None,
|
||||||
|
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
|
||||||
|
"duration": self.duration,
|
||||||
|
"created_by": self.created_by,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class Case(Base):
|
class Case(Base):
|
||||||
@@ -115,13 +417,13 @@ class Case(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
title = Column(String, nullable=False)
|
title = Column(String, nullable=False)
|
||||||
field = Column(String, nullable=False) # 对应四大支柱或其子领域
|
field = Column(String, nullable=False)
|
||||||
summary = Column(Text)
|
summary = Column(Text)
|
||||||
key_metrics = Column(Text, nullable=True) # 关键数据
|
key_metrics = Column(Text, nullable=True)
|
||||||
date = Column(String, nullable=True) # 日期或年份字符串
|
date = Column(String, nullable=True)
|
||||||
source = Column(String, nullable=True)
|
source = Column(String, nullable=True)
|
||||||
credibility_rating = Column(String, nullable=True) # 如 "⭐⭐⭐"
|
credibility_rating = Column(String, nullable=True)
|
||||||
china_applicability = Column(String, nullable=True) # 如 "⭐⭐⭐⭐"
|
china_applicability = Column(String, nullable=True)
|
||||||
source_url = Column(String, nullable=True)
|
source_url = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
@@ -147,13 +449,13 @@ class TaskLog(Base):
|
|||||||
__tablename__ = "task_logs"
|
__tablename__ = "task_logs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
task_name = Column(String, nullable=False) # collector, creator, research, outline, writer, optimizer
|
task_name = Column(String, nullable=False)
|
||||||
topic_id = Column(String, nullable=True) # 关联选题ID
|
topic_id = Column(String, nullable=True)
|
||||||
status = Column(String, nullable=False) # started, completed, failed
|
status = Column(String, nullable=False)
|
||||||
message = Column(Text, nullable=True)
|
message = Column(Text, nullable=True)
|
||||||
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
started_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
finished_at = Column(DateTime(timezone=True), nullable=True)
|
finished_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
duration = Column(Integer, nullable=True) # 秒数
|
duration = Column(Integer, nullable=True)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@@ -172,12 +474,12 @@ class LLMConfig(Base):
|
|||||||
__tablename__ = "llm_configs"
|
__tablename__ = "llm_configs"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
name = Column(String, unique=True, nullable=False) # 如 default_expand
|
name = Column(String, unique=True, nullable=False)
|
||||||
system_prompt = Column(Text, nullable=True)
|
system_prompt = Column(Text, nullable=True)
|
||||||
user_prompt_template = Column(Text, nullable=False) # 含占位符 {topic.get('title')} 等
|
user_prompt_template = Column(Text, nullable=False)
|
||||||
temperature = Column(Float, default=0.7)
|
temperature = Column(Float, default=0.7)
|
||||||
max_tokens = Column(Integer, default=2000)
|
max_tokens = Column(Integer, default=2000)
|
||||||
model = Column(String, nullable=True) # 后端模型名
|
model = Column(String, nullable=True)
|
||||||
is_active = Column(Boolean, default=True)
|
is_active = Column(Boolean, default=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
@@ -202,7 +504,7 @@ class SystemConfig(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||||
key = Column(String, unique=True, nullable=False)
|
key = Column(String, unique=True, nullable=False)
|
||||||
value = Column(Text, nullable=True) # 可存储 JSON 字符串
|
value = Column(Text, nullable=True)
|
||||||
description = Column(String, nullable=True)
|
description = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
@@ -217,6 +519,3 @@ class SystemConfig(Base):
|
|||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- 新增模型:案例库 ---
|
|
||||||
|
|||||||
+361
-66
@@ -1,7 +1,336 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
|
||||||
|
class TopicFieldBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
icon: Optional[str] = None
|
||||||
|
color: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
parent_id: Optional[int] = None
|
||||||
|
sort_order: int = 0
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TopicFieldResponse(TopicFieldBase):
|
||||||
|
id: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TopicConfigFieldBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
key: str
|
||||||
|
field_type: str = "number"
|
||||||
|
weight: float = 1.0
|
||||||
|
options: List[Any] = []
|
||||||
|
min_value: Optional[float] = None
|
||||||
|
max_value: Optional[float] = None
|
||||||
|
is_required: bool = False
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class TopicConfigFieldResponse(TopicConfigFieldBase):
|
||||||
|
id: int
|
||||||
|
field_id: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TopicStatusConfigBase(BaseModel):
|
||||||
|
status: str
|
||||||
|
label: str
|
||||||
|
color: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
sort_order: int = 0
|
||||||
|
is_default: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class TopicStatusConfigResponse(TopicStatusConfigBase):
|
||||||
|
id: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TopicBase(BaseModel):
|
||||||
|
id: str
|
||||||
|
field_id: Optional[int] = None
|
||||||
|
field_name: Optional[str] = None
|
||||||
|
title: str
|
||||||
|
format: Optional[str] = None
|
||||||
|
core_concept: Optional[str] = None
|
||||||
|
audience_pain: Optional[str] = None
|
||||||
|
unique_angle: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
priority_score: int = 0
|
||||||
|
total_score: Optional[float] = None
|
||||||
|
status: str = "pending"
|
||||||
|
tags: List[str] = []
|
||||||
|
custom_data: Dict[str, Any] = {}
|
||||||
|
scoring_data: Dict[str, Any] = {}
|
||||||
|
lock_by: Optional[str] = None
|
||||||
|
lock_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TopicCreate(BaseModel):
|
||||||
|
id: Optional[str] = None
|
||||||
|
field_id: Optional[int] = None
|
||||||
|
title: str
|
||||||
|
format: Optional[str] = None
|
||||||
|
core_concept: Optional[str] = None
|
||||||
|
audience_pain: Optional[str] = None
|
||||||
|
unique_angle: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
tags: List[str] = []
|
||||||
|
custom_data: Dict[str, Any] = {}
|
||||||
|
scoring_data: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class TopicUpdate(BaseModel):
|
||||||
|
field_id: Optional[int] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
format: Optional[str] = None
|
||||||
|
core_concept: Optional[str] = None
|
||||||
|
audience_pain: Optional[str] = None
|
||||||
|
unique_angle: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
custom_data: Optional[Dict[str, Any]] = None
|
||||||
|
scoring_data: Optional[Dict[str, Any]] = None
|
||||||
|
priority_score: Optional[int] = None
|
||||||
|
total_score: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class TopicResponse(TopicBase):
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
generated_at: Optional[datetime] = None
|
||||||
|
ready_at: Optional[date] = None
|
||||||
|
published_at: Optional[date] = None
|
||||||
|
compliance_score: Optional[int] = None
|
||||||
|
platform_urls: Dict[str, str] = {}
|
||||||
|
cases: List[Any] = []
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TopicScoreRequest(BaseModel):
|
||||||
|
scoring_data: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleBase(BaseModel):
|
||||||
|
id: str
|
||||||
|
topic_id: str
|
||||||
|
platform: str
|
||||||
|
file_path: str
|
||||||
|
status: str = "draft"
|
||||||
|
compliance_score: Optional[int] = None
|
||||||
|
word_count: Optional[int] = None
|
||||||
|
outline: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticleResponse(ArticleBase):
|
||||||
|
html_content: Optional[str] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentCalendarBase(BaseModel):
|
||||||
|
topic_id: Optional[str] = None
|
||||||
|
field_id: Optional[int] = None
|
||||||
|
title: str
|
||||||
|
planned_date: date
|
||||||
|
platform: Optional[str] = None
|
||||||
|
status: str = "planned"
|
||||||
|
reminder_time: Optional[datetime] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentCalendarCreate(ContentCalendarBase):
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentCalendarUpdate(BaseModel):
|
||||||
|
topic_id: Optional[str] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
planned_date: Optional[date] = None
|
||||||
|
published_date: Optional[date] = None
|
||||||
|
platform: Optional[str] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
reminder_time: Optional[datetime] = None
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentCalendarResponse(ContentCalendarBase):
|
||||||
|
id: int
|
||||||
|
published_date: Optional[date] = None
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentMetricsBase(BaseModel):
|
||||||
|
topic_id: str
|
||||||
|
platform: str
|
||||||
|
publish_url: Optional[str] = None
|
||||||
|
views: int = 0
|
||||||
|
likes: int = 0
|
||||||
|
favorites: int = 0
|
||||||
|
comments: int = 0
|
||||||
|
shares: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class ContentMetricsCreate(ContentMetricsBase):
|
||||||
|
data_snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentMetricsUpdate(BaseModel):
|
||||||
|
publish_url: Optional[str] = None
|
||||||
|
views: Optional[int] = None
|
||||||
|
likes: Optional[int] = None
|
||||||
|
favorites: Optional[int] = None
|
||||||
|
comments: Optional[int] = None
|
||||||
|
shares: Optional[int] = None
|
||||||
|
data_snapshot: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentMetricsResponse(ContentMetricsBase):
|
||||||
|
id: int
|
||||||
|
engagement_rate: float = 0
|
||||||
|
last_fetched: Optional[datetime] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MediaAssetBase(BaseModel):
|
||||||
|
filename: str
|
||||||
|
file_path: str
|
||||||
|
file_url: Optional[str] = None
|
||||||
|
file_type: str
|
||||||
|
mime_type: Optional[str] = None
|
||||||
|
size: Optional[int] = None
|
||||||
|
width: Optional[int] = None
|
||||||
|
height: Optional[int] = None
|
||||||
|
thumbnail_path: Optional[str] = None
|
||||||
|
alt_text: Optional[str] = None
|
||||||
|
tags: List[str] = []
|
||||||
|
topic_ids: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MediaAssetCreate(MediaAssetBase):
|
||||||
|
uploaded_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MediaAssetUpdate(BaseModel):
|
||||||
|
alt_text: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
topic_ids: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class MediaAssetResponse(MediaAssetBase):
|
||||||
|
id: int
|
||||||
|
usage_count: int = 0
|
||||||
|
uploaded_by: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformConfigBase(BaseModel):
|
||||||
|
platform: str
|
||||||
|
name: str
|
||||||
|
icon: Optional[str] = None
|
||||||
|
api_endpoint: Optional[str] = None
|
||||||
|
auth_config: Dict[str, Any] = {}
|
||||||
|
format_template: Dict[str, Any] = {}
|
||||||
|
compliance_rules: Dict[str, Any] = {}
|
||||||
|
default_format: Optional[str] = None
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformConfigCreate(PlatformConfigBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformConfigUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
icon: Optional[str] = None
|
||||||
|
api_endpoint: Optional[str] = None
|
||||||
|
auth_config: Optional[Dict[str, Any]] = None
|
||||||
|
format_template: Optional[Dict[str, Any]] = None
|
||||||
|
compliance_rules: Optional[Dict[str, Any]] = None
|
||||||
|
default_format: Optional[str] = None
|
||||||
|
is_active: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PlatformConfigResponse(PlatformConfigBase):
|
||||||
|
id: int
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentTaskBase(BaseModel):
|
||||||
|
topic_id: Optional[str] = None
|
||||||
|
stage: str
|
||||||
|
status: str = "pending"
|
||||||
|
progress: int = 0
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentTaskCreate(BaseModel):
|
||||||
|
topic_id: Optional[str] = None
|
||||||
|
stage: str
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContentTaskResponse(ContentTaskBase):
|
||||||
|
id: int
|
||||||
|
task_id: str
|
||||||
|
result_data: Dict[str, Any] = {}
|
||||||
|
error_msg: Optional[str] = None
|
||||||
|
started_at: Optional[datetime] = None
|
||||||
|
finished_at: Optional[datetime] = None
|
||||||
|
duration: Optional[int] = None
|
||||||
|
created_by: Optional[str] = None
|
||||||
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsDashboard(BaseModel):
|
||||||
|
total_topics: int
|
||||||
|
topics_by_status: Dict[str, int]
|
||||||
|
total_published: int
|
||||||
|
total_views: int
|
||||||
|
total_likes: int
|
||||||
|
avg_engagement_rate: float
|
||||||
|
top_topics: List[Dict[str, Any]]
|
||||||
|
recent_metrics: List[ContentMetricsResponse]
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarMonthResponse(BaseModel):
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
entries: List[ContentCalendarResponse]
|
||||||
|
stats: Dict[str, int]
|
||||||
|
|
||||||
|
|
||||||
class AuditLogBase(BaseModel):
|
class AuditLogBase(BaseModel):
|
||||||
user_id: Optional[int] = None
|
user_id: Optional[int] = None
|
||||||
username: str
|
username: str
|
||||||
@@ -13,85 +342,51 @@ class AuditLogBase(BaseModel):
|
|||||||
user_agent: Optional[str] = None
|
user_agent: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class AuditLogResponse(AuditLogBase):
|
class AuditLogResponse(AuditLogBase):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
class UserBase(BaseModel):
|
class UserBase(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
role: str = "user"
|
role: str = "user"
|
||||||
|
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
role: Optional[str] = None
|
role: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
class LoginRequest(BaseModel):
|
class LoginRequest(BaseModel):
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
class TokenResponse(BaseModel):
|
class TokenResponse(BaseModel):
|
||||||
token: str
|
token: str
|
||||||
role: str
|
role: str
|
||||||
user: UserResponse
|
user: UserResponse
|
||||||
|
|
||||||
class TopicBase(BaseModel):
|
|
||||||
id: str
|
|
||||||
title: str
|
|
||||||
field: str
|
|
||||||
priority_score: int = 0
|
|
||||||
status: str = "pending"
|
|
||||||
compliance_score: Optional[int] = None
|
|
||||||
ready_at: Optional[date] = None
|
|
||||||
published_at: Optional[date] = None
|
|
||||||
generated_at: Optional[datetime] = None
|
|
||||||
platform_urls: Optional[Dict[str, str]] = None
|
|
||||||
|
|
||||||
class TopicResponse(TopicBase):
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
class ArticleBase(BaseModel):
|
|
||||||
id: str
|
|
||||||
topic_id: str
|
|
||||||
platform: str
|
|
||||||
file_path: str
|
|
||||||
status: str = "draft"
|
|
||||||
compliance_score: Optional[int] = None
|
|
||||||
created_at: Optional[datetime] = None
|
|
||||||
|
|
||||||
class ArticleResponse(ArticleBase):
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
class SystemStatus(BaseModel):
|
|
||||||
total_topics: int
|
|
||||||
topics_by_status: Dict[str, int]
|
|
||||||
ready_topics: List[TopicResponse]
|
|
||||||
today_articles: int
|
|
||||||
compliance_rate: float
|
|
||||||
last_optimization: Optional[datetime] = None
|
|
||||||
execution_time: Optional[float] = None
|
|
||||||
|
|
||||||
class OptimizationRequest(BaseModel):
|
|
||||||
topic_ids: Optional[List[str]] = None
|
|
||||||
|
|
||||||
class PublishRequest(BaseModel):
|
class PublishRequest(BaseModel):
|
||||||
topic_id: str
|
topic_id: str
|
||||||
platform_urls: Dict[str, str]
|
platform_urls: Dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
class PublishActionRequest(BaseModel):
|
class PublishActionRequest(BaseModel):
|
||||||
action: str
|
action: str
|
||||||
platform: Optional[str] = None
|
platform: Optional[str] = None
|
||||||
@@ -102,6 +397,7 @@ class PublishActionRequest(BaseModel):
|
|||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
error_msg: Optional[str] = None
|
error_msg: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class PublishRecordResponse(BaseModel):
|
class PublishRecordResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
topic_id: str
|
topic_id: str
|
||||||
@@ -117,26 +413,7 @@ class PublishRecordResponse(BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
class BatchPublishRequest(BaseModel):
|
|
||||||
date: str
|
|
||||||
|
|
||||||
|
|
||||||
class AuditLogResponse(BaseModel):
|
|
||||||
id: int
|
|
||||||
user_id: Optional[int] = None
|
|
||||||
username: str
|
|
||||||
action: str
|
|
||||||
resource_type: Optional[str] = None
|
|
||||||
resource_id: Optional[str] = None
|
|
||||||
details: Optional[Dict[str, Any]] = None
|
|
||||||
ip_address: Optional[str] = None
|
|
||||||
user_agent: Optional[str] = None
|
|
||||||
created_at: datetime
|
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
|
||||||
|
|
||||||
|
|
||||||
# --- 案例库 Schema ---
|
|
||||||
class CaseBase(BaseModel):
|
class CaseBase(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
field: str
|
field: str
|
||||||
@@ -148,6 +425,7 @@ class CaseBase(BaseModel):
|
|||||||
credibility_rating: Optional[str] = None
|
credibility_rating: Optional[str] = None
|
||||||
china_applicability: Optional[str] = None
|
china_applicability: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class CaseResponse(CaseBase):
|
class CaseResponse(CaseBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@@ -156,7 +434,6 @@ class CaseResponse(CaseBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# --- 任务日志 Schema ---
|
|
||||||
class TaskLogBase(BaseModel):
|
class TaskLogBase(BaseModel):
|
||||||
task_name: str
|
task_name: str
|
||||||
topic_id: Optional[str] = None
|
topic_id: Optional[str] = None
|
||||||
@@ -166,13 +443,13 @@ class TaskLogBase(BaseModel):
|
|||||||
finished_at: Optional[datetime] = None
|
finished_at: Optional[datetime] = None
|
||||||
duration_seconds: Optional[int] = None
|
duration_seconds: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
class TaskLogResponse(TaskLogBase):
|
class TaskLogResponse(TaskLogBase):
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# --- LLM 配置 Schema ---
|
|
||||||
class LLMConfigBase(BaseModel):
|
class LLMConfigBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
system_prompt: Optional[str] = None
|
system_prompt: Optional[str] = None
|
||||||
@@ -182,6 +459,7 @@ class LLMConfigBase(BaseModel):
|
|||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
is_active: bool = True
|
is_active: bool = True
|
||||||
|
|
||||||
|
|
||||||
class LLMConfigResponse(LLMConfigBase):
|
class LLMConfigResponse(LLMConfigBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
@@ -190,14 +468,31 @@ class LLMConfigResponse(LLMConfigBase):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
# --- 系统配置 Schema ---
|
|
||||||
class SystemConfigBase(BaseModel):
|
class SystemConfigBase(BaseModel):
|
||||||
key: str
|
key: str
|
||||||
value: Optional[str] = None
|
value: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SystemConfigResponse(SystemConfigBase):
|
class SystemConfigResponse(SystemConfigBase):
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemStatus(BaseModel):
|
||||||
|
total_topics: int
|
||||||
|
topics_by_status: Dict[str, int]
|
||||||
|
ready_topics: List[TopicResponse]
|
||||||
|
today_articles: int
|
||||||
|
compliance_rate: float
|
||||||
|
last_optimization: Optional[datetime] = None
|
||||||
|
execution_time: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationRequest(BaseModel):
|
||||||
|
topic_ids: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class BatchPublishRequest(BaseModel):
|
||||||
|
date: str
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user