feat: 全面升级项目架构 - PostgreSQL迁移 + 配置化改造

主要变更:
- 数据库: SQLite → PostgreSQL (yzr_nr)
- 选题系统: 硬编码字段 → 配置化 (TopicField/TopicConfigField/TopicStatusConfig)
- 新增模型: ContentCalendar, ContentMetrics, MediaAsset, PlatformConfig, ContentTask
- 新增 API: topic-config, calendar, metrics, assets, tasks, platform-config
- 数据迁移: 现有选题数据迁移到新 schema (field_id/tags/custom_data/scoring_data)
- 初始化数据: 10个领域, 5种状态, 3个平台配置

服务运行: http://localhost:8001
默认账号: admin / admin123
This commit is contained in:
lt
2026-05-08 18:26:01 +08:00
parent ac48304532
commit cf5103bbca
14 changed files with 2701 additions and 344 deletions
+347 -48
View File
@@ -1,5 +1,6 @@
from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON
from sqlalchemy import Column, String, Integer, Float, Date, DateTime, Text, Boolean, JSON, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from .database import Base
from datetime import datetime
@@ -8,14 +9,14 @@ class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, nullable=True, index=True) # 操作用户ID(未登录/匿名可为空)
username = Column(String, nullable=False) # 操作用户名(冗余存储)
action = Column(String, nullable=False, index=True) # 操作类型: login/logout/create_user/update_user/delete_user/publish/etc.
resource_type = Column(String, nullable=True, index=True) # 资源类型: user/topic/publish_record/etc.
resource_id = Column(String, nullable=True) # 资源ID
details = Column(JSON, default=dict, nullable=True) # 操作详情(变更前后、额外信息等)
ip_address = Column(String, nullable=True) # IP 地址
user_agent = Column(String, nullable=True) # User-Agent
user_id = Column(Integer, nullable=True, index=True)
username = Column(String, nullable=False)
action = Column(String, nullable=False, index=True)
resource_type = Column(String, nullable=True, index=True)
resource_id = Column(String, nullable=True)
details = Column(JSON, default=dict, nullable=True)
ip_address = Column(String, nullable=True)
user_agent = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
@@ -38,8 +39,8 @@ class User(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
username = Column(String, unique=True, nullable=False, index=True)
password_hash = Column(String, nullable=False) # bcrypt 哈希
role = Column(String, default="user", nullable=False) # admin/user
password_hash = Column(String, nullable=False)
role = Column(String, default="user", nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -52,62 +53,363 @@ class User(Base):
}
class TopicField(Base):
__tablename__ = "topic_fields"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, nullable=False)
icon = Column(String, nullable=True)
color = Column(String, nullable=True)
description = Column(Text, nullable=True)
parent_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
parent = relationship("TopicField", remote_side=[id], backref="children")
scoring_fields = relationship("TopicConfigField", back_populates="field", cascade="all, delete-orphan")
def to_dict(self):
return {
"id": self.id,
"name": self.name,
"icon": self.icon,
"color": self.color,
"description": self.description,
"parent_id": self.parent_id,
"sort_order": self.sort_order,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class TopicConfigField(Base):
__tablename__ = "topic_config_fields"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=False)
name = Column(String, nullable=False)
key = Column(String, nullable=False)
field_type = Column(String, default="number") # number/select/multi_select/text
weight = Column(Float, default=1.0)
options = Column(JSON, default=list) # for select/multi_select
min_value = Column(Float, nullable=True)
max_value = Column(Float, nullable=True)
is_required = Column(Boolean, default=False)
sort_order = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
field = relationship("TopicField", back_populates="scoring_fields")
def to_dict(self):
return {
"id": self.id,
"field_id": self.field_id,
"name": self.name,
"key": self.key,
"field_type": self.field_type,
"weight": self.weight,
"options": self.options or [],
"min_value": self.min_value,
"max_value": self.max_value,
"is_required": self.is_required,
"sort_order": self.sort_order,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class TopicStatusConfig(Base):
__tablename__ = "topic_status_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
status = Column(String, unique=True, nullable=False)
label = Column(String, nullable=False)
color = Column(String, nullable=True)
icon = Column(String, nullable=True)
sort_order = Column(Integer, default=0)
is_default = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id,
"status": self.status,
"label": self.label,
"color": self.color,
"icon": self.icon,
"sort_order": self.sort_order,
"is_default": self.is_default,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class Topic(Base):
__tablename__ = "topics"
id = Column(String, primary_key=True, index=True)
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
field_name = Column(String, nullable=True)
title = Column(String, nullable=False)
field = Column(String, nullable=False)
format = Column(String)
core_concept = Column(Text)
audience_pain = Column(Text)
unique_angle = Column(Text)
priority = Column(String) # 高/中
priority = Column(String)
priority_score = Column(Integer, default=0)
total_score = Column(Float)
status = Column(String, default="pending") # pending/draft/ready/published
status = Column(String, default="pending")
cases = Column(JSON, default=list)
source_file = Column(String)
tags = Column(JSON, default=list)
custom_data = Column(JSON, default=dict)
scoring_data = Column(JSON, default=dict)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
generated_at = Column(DateTime(timezone=True), nullable=True) # 选题创作完成时间
generated_at = Column(DateTime(timezone=True), nullable=True)
ready_at = Column(Date)
published_at = Column(Date)
compliance_score = Column(Integer)
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
platform_urls = Column(JSON, default=dict)
lock_by = Column(String, nullable=True)
lock_at = Column(DateTime, nullable=True)
field = relationship("TopicField", backref="topics")
@property
def field_info(self):
if self.field:
return self.field.to_dict()
return None
class Article(Base):
__tablename__ = "articles"
id = Column(String, primary_key=True) # e.g., A01_zhihu
topic_id = Column(String, nullable=False)
id = Column(String, primary_key=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
platform = Column(String, nullable=False)
file_path = Column(String, nullable=False)
status = Column(String, default="draft") # draft/optimized/published
status = Column(String, default="draft")
created_at = Column(DateTime(timezone=True), server_default=func.now())
compliance_score = Column(Integer)
html_content = Column(Text) # 可缓存HTML内容以便预览
html_content = Column(Text)
word_count = Column(Integer, nullable=True)
outline = Column(Text, nullable=True)
class PublishRecord(Base):
__tablename__ = "publish_records"
id = Column(Integer, primary_key=True, index=True)
topic_id = Column(String, nullable=False)
platform = Column(String, nullable=False) # 发布平台:zhihu/wechat/xiaohongshu
action = Column(String, nullable=False) # 操作:publish/update/delete/invalid
status = Column(String, nullable=False) # 状态:success/failed/partial/cancelled
operator = Column(String, nullable=True) # 操作人
description = Column(Text, nullable=True) # 发布说明
suggestion = Column(Text, nullable=True) # 建议内容
url = Column(String, nullable=True) # 发布后链接
error_msg = Column(Text, nullable=True) # 错误信息
topic_id = Column(String, ForeignKey("topics.id"), nullable=False)
platform = Column(String, nullable=False)
action = Column(String, nullable=False)
status = Column(String, nullable=False)
operator = Column(String, nullable=True)
description = Column(Text, nullable=True)
suggestion = Column(Text, nullable=True)
url = Column(String, nullable=True)
error_msg = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关联查询
# 可以添加外键关联到 Topic, 但这里保持简单
topic = relationship("Topic")
class ContentCalendar(Base):
__tablename__ = "content_calendar"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=True)
field_id = Column(Integer, ForeignKey("topic_fields.id"), nullable=True)
title = Column(String, nullable=False)
planned_date = Column(Date, nullable=False, index=True)
published_date = Column(Date, nullable=True)
platform = Column(String, nullable=True)
status = Column(String, default="planned") # planned/published/delayed/cancelled
reminder_time = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
topic = relationship("Topic")
def to_dict(self):
return {
"id": self.id,
"topic_id": self.topic_id,
"field_id": self.field_id,
"title": self.title,
"planned_date": self.planned_date.isoformat() if self.planned_date else None,
"published_date": self.published_date.isoformat() if self.published_date else None,
"platform": self.platform,
"status": self.status,
"reminder_time": self.reminder_time.isoformat() if self.reminder_time else None,
"notes": self.notes,
"created_by": self.created_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class ContentMetrics(Base):
__tablename__ = "content_metrics"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=False, index=True)
platform = Column(String, nullable=False)
publish_url = Column(String, nullable=True)
views = Column(Integer, default=0)
likes = Column(Integer, default=0)
favorites = Column(Integer, default=0)
comments = Column(Integer, default=0)
shares = Column(Integer, default=0)
last_fetched = Column(DateTime(timezone=True), nullable=True)
data_snapshot = Column(JSON, default=dict)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
topic = relationship("Topic")
@property
def engagement_rate(self):
total = self.views or 0
if total == 0:
return 0
return round((self.likes or 0) / total * 100, 2)
def to_dict(self):
return {
"id": self.id,
"topic_id": self.topic_id,
"platform": self.platform,
"publish_url": self.publish_url,
"views": self.views,
"likes": self.likes,
"favorites": self.favorites,
"comments": self.comments,
"shares": self.shares,
"engagement_rate": self.engagement_rate,
"last_fetched": self.last_fetched.isoformat() if self.last_fetched else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class MediaAsset(Base):
__tablename__ = "media_assets"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
filename = Column(String, nullable=False)
file_path = Column(String, nullable=False)
file_url = Column(String, nullable=True)
file_type = Column(String, nullable=False) # image/video/document
mime_type = Column(String, nullable=True)
size = Column(Integer, nullable=True)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
thumbnail_path = Column(String, nullable=True)
alt_text = Column(String, nullable=True)
tags = Column(JSON, default=list)
topic_ids = Column(JSON, default=list)
usage_count = Column(Integer, default=0)
uploaded_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"filename": self.filename,
"file_path": self.file_path,
"file_url": self.file_url,
"file_type": self.file_type,
"mime_type": self.mime_type,
"size": self.size,
"width": self.width,
"height": self.height,
"thumbnail_path": self.thumbnail_path,
"alt_text": self.alt_text,
"tags": self.tags or [],
"topic_ids": self.topic_ids or [],
"usage_count": self.usage_count,
"uploaded_by": self.uploaded_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class PlatformConfig(Base):
__tablename__ = "platform_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
platform = Column(String, unique=True, nullable=False) # zhihu/wechat/xiaohongshu
name = Column(String, nullable=False)
icon = Column(String, nullable=True)
api_endpoint = Column(String, nullable=True)
auth_config = Column(JSON, default=dict)
format_template = Column(JSON, default=dict)
compliance_rules = Column(JSON, default=dict)
default_format = Column(Text, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"platform": self.platform,
"name": self.name,
"icon": self.icon,
"api_endpoint": self.api_endpoint,
"format_template": self.format_template or {},
"compliance_rules": self.compliance_rules or {},
"default_format": self.default_format,
"is_active": self.is_active,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class ContentTask(Base):
__tablename__ = "content_tasks"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
topic_id = Column(String, ForeignKey("topics.id"), nullable=True, index=True)
task_id = Column(String, unique=True, nullable=False)
stage = Column(String, nullable=False) # research/outline/writer/optimizer/format/publish
status = Column(String, default="pending") # pending/running/completed/failed/cancelled
progress = Column(Integer, default=0)
message = Column(Text, nullable=True)
result_data = Column(JSON, default=dict)
error_msg = Column(Text, nullable=True)
started_at = Column(DateTime(timezone=True), nullable=True)
finished_at = Column(DateTime(timezone=True), nullable=True)
duration = Column(Integer, nullable=True)
created_by = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
topic = relationship("Topic")
def to_dict(self):
return {
"id": self.id,
"topic_id": self.topic_id,
"task_id": self.task_id,
"stage": self.stage,
"status": self.status,
"progress": self.progress,
"message": self.message,
"error_msg": self.error_msg,
"started_at": self.started_at.isoformat() if self.started_at else None,
"finished_at": self.finished_at.isoformat() if self.finished_at else None,
"duration": self.duration,
"created_by": self.created_by,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
class Case(Base):
@@ -115,13 +417,13 @@ class Case(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
title = Column(String, nullable=False)
field = Column(String, nullable=False) # 对应四大支柱或其子领域
field = Column(String, nullable=False)
summary = Column(Text)
key_metrics = Column(Text, nullable=True) # 关键数据
date = Column(String, nullable=True) # 日期或年份字符串
key_metrics = Column(Text, nullable=True)
date = Column(String, nullable=True)
source = Column(String, nullable=True)
credibility_rating = Column(String, nullable=True) # 如 "⭐⭐⭐"
china_applicability = Column(String, nullable=True) # 如 "⭐⭐⭐⭐"
credibility_rating = Column(String, nullable=True)
china_applicability = Column(String, nullable=True)
source_url = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -147,13 +449,13 @@ class TaskLog(Base):
__tablename__ = "task_logs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
task_name = Column(String, nullable=False) # collector, creator, research, outline, writer, optimizer
topic_id = Column(String, nullable=True) # 关联选题ID
status = Column(String, nullable=False) # started, completed, failed
task_name = Column(String, nullable=False)
topic_id = Column(String, nullable=True)
status = Column(String, nullable=False)
message = Column(Text, nullable=True)
started_at = Column(DateTime(timezone=True), server_default=func.now())
finished_at = Column(DateTime(timezone=True), nullable=True)
duration = Column(Integer, nullable=True) # 秒数
duration = Column(Integer, nullable=True)
def to_dict(self):
return {
@@ -172,12 +474,12 @@ class LLMConfig(Base):
__tablename__ = "llm_configs"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String, unique=True, nullable=False) # 如 default_expand
name = Column(String, unique=True, nullable=False)
system_prompt = Column(Text, nullable=True)
user_prompt_template = Column(Text, nullable=False) # 含占位符 {topic.get('title')} 等
user_prompt_template = Column(Text, nullable=False)
temperature = Column(Float, default=0.7)
max_tokens = Column(Integer, default=2000)
model = Column(String, nullable=True) # 后端模型名
model = Column(String, nullable=True)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -202,7 +504,7 @@ class SystemConfig(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
key = Column(String, unique=True, nullable=False)
value = Column(Text, nullable=True) # 可存储 JSON 字符串
value = Column(Text, nullable=True)
description = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
@@ -216,7 +518,4 @@ class SystemConfig(Base):
"description": self.description,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
# --- 新增模型:案例库 ---
}