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