feat: 宇之然平台 v2.0 - 完整重构版

核心功能:
- 新增 JWT 认证系统,支持管理员登录/登出
- 前后端合并为单一 FastAPI 应用 (端口 8001)
- 系统概览页:6 个统计卡片,点击跳转筛选
- 选题管理页:批量操作 (刷新/创作/优化),时间列展示
- 系统日志页:整合日志查看功能
- 用户管理页:管理员可创建/删除用户
- 移动端适配:响应式布局,底部导航栏
- 标题居中显示

技术改进:
- 添加 generated_at 字段支持创作时间记录
- 状态更新时自动同步 updated_at
- 所有 API 路由添加 JWT 认证保护
- 前端 authFetch 封装自动附加 Token
- 升级 FastAPI 0.136, Pydantic 2.13 等依赖

修复:
- 修复 API 500 错误 (数据库列缺失)
- 修复 formatRelativeTime 未定义错误
- 修复登录 Token 存储和自动附加逻辑
This commit is contained in:
lt
2026-04-27 10:01:33 +08:00
parent 3cb2df51c8
commit 2bd3c83cf5
14 changed files with 1241 additions and 645 deletions
+146
View File
@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import List
import bcrypt
from ..database import get_db
from ..models import User
from ..schemas import UserCreate, UserUpdate, UserResponse
from ..core.audit_logger import audit_log
router = APIRouter(prefix="/api/admin", tags=["admin"])
def get_current_admin(request: Request, db: Session = Depends(get_db)):
"""依赖项:验证管理员权限"""
from .auth import verify_token
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
token = auth_header.split(" ")[1]
user = verify_token(token, db)
if user.role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
@router.get("/users", response_model=List[UserResponse])
def list_users(
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
"""获取用户列表(管理员)"""
users = db.query(User).all()
return [UserResponse.from_orm(u) for u in users]
@router.post("/users", response_model=UserResponse)
def create_user(
user_data: UserCreate,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
"""创建新用户(管理员)"""
# 检查用户名是否已存在
existing = db.query(User).filter(User.username == user_data.username).first()
if existing:
raise HTTPException(status_code=400, detail="用户名已存在")
# bcrypt 密码哈希
hashed_password = bcrypt.hashpw(user_data.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
user = User(
username=user_data.username,
password_hash=hashed_password,
role=user_data.role or "user"
)
db.add(user)
db.commit()
db.refresh(user)
audit_log(
action="create_user",
user=admin_user,
resource_type="user",
resource_id=str(user.id),
details={"username": user.username, "role": user.role},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return UserResponse.from_orm(user)
@router.put("/users/{user_id}", response_model=UserResponse)
def update_user(
user_id: int,
user_update: UserUpdate,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
"""更新用户(管理员)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
changes = {}
if user_update.username is not None:
if user_update.username != user.username:
existing = db.query(User).filter(User.username == user_update.username).first()
if existing:
raise HTTPException(status_code=400, detail="用户名已存在")
changes["username"] = {"old": user.username, "new": user_update.username}
user.username = user_update.username
if user_update.password is not None:
changes["password"] = {"changed": True}
user.password_hash = bcrypt.hashpw(user_update.password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
if user_update.role is not None and user_update.role != user.role:
changes["role"] = {"old": user.role, "new": user_update.role}
user.role = user_update.role
db.commit()
db.refresh(user)
audit_log(
action="update_user",
user=admin_user,
resource_type="user",
resource_id=str(user.id),
details=changes,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return UserResponse.from_orm(user)
@router.delete("/users/{user_id}")
def delete_user(
user_id: int,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
"""删除用户(管理员)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.role == "admin":
raise HTTPException(status_code=400, detail="不能删除管理员用户")
username = user.username # 保存用于日志
db.delete(user)
db.commit()
audit_log(
action="delete_user",
user=admin_user,
resource_type="user",
resource_id=str(user_id),
details={"username": username},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return {"message": "删除成功"}
+72
View File
@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, timedelta
from ..database import get_db
from ..models import AuditLog
from ..schemas import AuditLogResponse
from .auth import verify_token
router = APIRouter(prefix="/api/audit", tags=["audit"])
def get_current_admin(request: Request, db: Session = Depends(get_db)):
"""依赖项:验证管理员权限"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
token = auth_header.split(" ")[1]
user = verify_token(token, db)
if user.role != "admin":
raise HTTPException(status_code=403, detail="需要管理员权限")
return user
@router.get("/logs", response_model=List[AuditLogResponse])
def list_audit_logs(
request: Request,
db: Session = Depends(get_db),
admin_user: bool = Depends(get_current_admin),
username: Optional[str] = Query(None, description="按用户名筛选"),
action: Optional[str] = Query(None, description="按操作类型筛选"),
resource_type: Optional[str] = Query(None, description="按资源类型筛选"),
start_date: Optional[datetime] = Query(None, description="开始时间"),
end_date: Optional[datetime] = Query(None, description="结束时间"),
limit: int = Query(100, ge=1, le=1000, description="返回数量限制")
):
"""查询审计日志(管理员)"""
query = db.query(AuditLog)
if username:
query = query.filter(AuditLog.username == username)
if action:
query = query.filter(AuditLog.action == action)
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if start_date:
query = query.filter(AuditLog.created_at >= start_date)
if end_date:
query = query.filter(AuditLog.created_at <= end_date)
logs = query.order_by(AuditLog.created_at.desc()).limit(limit).all()
return logs
@router.get("/logs/actions")
def list_audit_actions(
request: Request,
db: Session = Depends(get_db),
admin_user: bool = Depends(get_current_admin)
):
"""获取所有已记录的操作类型列表(用于筛选)"""
actions = db.query(AuditLog.action).distinct().all()
return [a[0] for a in actions if a[0]]
@router.get("/logs/users")
def list_audit_users(
request: Request,
db: Session = Depends(get_db),
admin_user: bool = Depends(get_current_admin),
limit: int = Query(50, ge=1, le=200)
):
"""获取最近产生审计记录的用户列表"""
users = db.query(AuditLog.username).distinct().order_by(AuditLog.created_at.desc()).limit(limit).all()
return [u[0] for u in users if u[0]]
+158
View File
@@ -0,0 +1,158 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import jwt
import bcrypt
import os
from dotenv import load_dotenv
from ..database import get_db
from ..models import User
from ..schemas import LoginRequest, TokenResponse, UserResponse
from ..core.audit_logger import audit_log
# 加载环境变量
load_dotenv()
router = APIRouter(prefix="/api/auth", tags=["auth"])
# JWT 配置
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-this")
ALGORITHM = os.getenv("ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_DAYS = int(os.getenv("ACCESS_TOKEN_EXPIRE_DAYS", "7"))
# 默认管理员配置
DEFAULT_ADMIN_USERNAME = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
DEFAULT_ADMIN_PASSWORD = os.getenv("DEFAULT_ADMIN_PASSWORD", "admin123")
def create_token(user: User) -> str:
"""生成 JWT token"""
expire = datetime.utcnow() + timedelta(days=ACCESS_TOKEN_EXPIRE_DAYS)
payload = {
"sub": str(user.id),
"username": user.username,
"role": user.role,
"exp": expire
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str, db: Session) -> User:
"""验证 JWT token 并返回用户"""
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id = int(payload["sub"])
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=401, detail="用户不存在")
return user
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="token 已过期")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="无效的 token")
@router.post("/login", response_model=TokenResponse)
def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_db)):
"""管理员登录(支持 bcrypt 密码验证)"""
client_ip = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
# 检查是否是默认管理员(bcrypt 验证)
if login_data.username == DEFAULT_ADMIN_USERNAME:
# 查询或创建管理员用户
user = db.query(User).filter(User.username == DEFAULT_ADMIN_USERNAME).first()
if not user:
# 首次创建,密码哈希存储
hashed = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt())
user = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=hashed.decode('utf-8'),
role="admin"
)
db.add(user)
db.commit()
db.refresh(user)
else:
# 验证密码
if not user.password_hash:
# 旧数据可能没有密码,设置为默认密码
user.password_hash = bcrypt.hashpw(DEFAULT_ADMIN_PASSWORD.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
db.commit()
else:
# 验证密码是否匹配
if not bcrypt.checkpw(login_data.password.encode('utf-8'), user.password_hash.encode('utf-8')):
# 记录失败日志
audit_log(
action="login_failed",
username=login_data.username,
details={"reason": "invalid_password"},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = create_token(user)
# 记录成功登录日志
audit_log(
action="login",
user=user,
details={"role": user.role},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
return TokenResponse(token=token, role=user.role, user=UserResponse.from_orm(user))
# 从数据库查询其他用户
user = db.query(User).filter(User.username == login_data.username).first()
if not user:
audit_log(
action="login_failed",
username=login_data.username,
details={"reason": "user_not_found"},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
# bcrypt 验证
if not user.password_hash or not bcrypt.checkpw(login_data.password.encode('utf-8'), user.password_hash.encode('utf-8')):
audit_log(
action="login_failed",
user=user,
details={"reason": "invalid_password"},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = create_token(user)
audit_log(
action="login",
user=user,
details={"role": user.role},
ip_address=client_ip,
user_agent=user_agent,
db=db
)
return TokenResponse(token=token, role=user.role, user=UserResponse.from_orm(user))
@router.get("/me")
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""获取当前登录用户信息"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
token = auth_header.split(" ")[1]
user = verify_token(token, db)
return {"user": UserResponse.from_orm(user)}
def get_current_user(request: Request, db: Session = Depends(get_db)):
"""依赖项:验证用户登录"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
token = auth_header.split(" ")[1]
user = verify_token(token, db)
return user
+6 -6
View File
@@ -4,6 +4,7 @@ from sqlalchemy import func
from datetime import datetime, date, timedelta
from typing import Dict, Any, List, Optional
from pathlib import Path
from .auth import get_current_user
import os
import json
from ..database import get_db
@@ -13,7 +14,7 @@ from ..core.generator import run_creator
from ..core.optimizer import run_optimizer
from ..core.sync import sync_topic_to_db, sync_all_topics
PROJECT_ROOT = Path(__file__).resolve().parents[4]
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if os.getenv('PROJECT_ROOT'):
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
@@ -57,7 +58,7 @@ def get_status(db: Session = Depends(get_db)):
last_optimization=last_opt.created_at if last_opt else None
)
@router.post("/generate/run")
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
"""手动触发内容创作任务
@@ -76,7 +77,7 @@ def trigger_generation(topic_id: str = None, db: Session = Depends(get_db)):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/optimize/run")
@router.post("/optimize/run", dependencies=[Depends(get_current_user)])
def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_db)):
"""手动触发合规优化任务
@@ -101,7 +102,7 @@ def trigger_optimization(topic_ids: List[str] = None, db: Session = Depends(get_
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/logs/{log_date}")
@router.get("/logs/{log_date}", dependencies=[Depends(get_current_user)])
def get_logs(log_date: str, log_type: str = "creator"):
"""读取日志文件内容,log_type: creator, optimizer, collector"""
log_file = LOGS_DIR / f"{log_type}_{log_date}.log"
@@ -111,7 +112,7 @@ def get_logs(log_date: str, log_type: str = "creator"):
lines = content.splitlines()[-100:] if log_type != "collector" else content.splitlines()[-200:]
return {"log_date": log_date, "log_type": log_type, "content": lines}
@router.get("/pipeline/status")
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
def get_pipeline_status():
"""获取流水线各模块状态(最后运行时间和结果)"""
try:
@@ -132,7 +133,6 @@ def get_pipeline_status():
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
"optimizer": LOGS_DIR / f"optimizer_{date.today().isoformat()}.log",
"publisher": LOGS_DIR / f"publisher_{date.today()}.log"
}
pipeline_status = {}
+151 -7
View File
@@ -1,19 +1,25 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from typing import List, Optional
from datetime import datetime, date
from pathlib import Path
from ..database import get_db
from ..models import Topic
from ..schemas import TopicResponse, PublishRequest
from ..models import Topic, PublishRecord
from ..schemas import TopicResponse, PublishRequest, PublishActionRequest, PublishRecordResponse
from .auth import get_current_user
router = APIRouter(prefix="/api/topics", tags=["topics"])
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,
db: Session = Depends(get_db)
):
# 确保读取最新数据,清除会话缓存
db.expire_all()
query = db.query(Topic)
if status:
query = query.filter(Topic.status == status)
@@ -35,9 +41,147 @@ def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_
if topic.status != "ready":
raise HTTPException(status_code=400, detail="Topic not in ready status")
# 更新选题状态
topic.status = "published"
topic.published_at = datetime.now().date()
topic.updated_at = datetime.now()
topic.platform_urls = req.platform_urls
db.commit()
return {"message": "Topic marked as published", "topic_id": topic_id}
# 创建发布记录
record = PublishRecord(
topic_id=topic_id,
platform=req.platform,
action="publish",
status="success",
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)
return {"message": "Topic marked as published", "topic_id": topic_id, "record_id": record.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)):
# 验证选题存在
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
# 更新发布记录
@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}/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.post("/{topic_id}/packages/generate")
def generate_packages(topic_id: str):
"""
手动触发单个选题的发布包生成。
相当于执行 publisher.py 针对单个选题。
"""
# TODO: 实际调用 publisher.py 逻辑,这里先返回模拟响应
return {"message": "Package generation triggered", "topic_id": topic_id, "status": "pending"}
+2 -1
View File
@@ -7,7 +7,7 @@ from ..models import Topic
import os
# 计算项目根目录(从本文件位置上升4层)
PROJECT_ROOT = Path(__file__).resolve().parents[4]
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"
@@ -43,6 +43,7 @@ def sync_topic_to_db(topic_id: str, db: Session = None) -> Topic:
db_topic.published_at = datetime.strptime(topic_data['published_at'], '%Y-%m-%d').date() if topic_data.get('published_at') else None
db_topic.compliance_score = topic_data.get('compliance_score', db_topic.compliance_score)
db_topic.platform_urls = topic_data.get('platform_urls', {})
db_topic.generated_at = datetime.now() if db_topic.generated_at is None and topic_data.get("status") in ["ready", "published"] else db_topic.generated_at
db_topic.updated_at = datetime.now()
db.commit()
db.refresh(db_topic)
+1 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
# 计算项目根目录(backend/app/database.py -> yu-zhi-ran
# __file__: platform/backend/app/database.py
# parents[0]=app, [1]=backend, [2]=platform, [3]=yu-zhi-ran
PROJECT_ROOT = Path(__file__).resolve().parents[3]
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent
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')
+35 -29
View File
@@ -1,23 +1,24 @@
import logging
from fastapi import FastAPI, Depends, HTTPException
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from datetime import datetime
from pathlib import Path
import os
from .database import engine, get_db, init_db
from .models import Base
from .api import topics, system, articles, publisher
from .initial_data import import_topics_from_json
from .api import topics, system, articles, publishing, auth, admin, audit
from .initial_data import import_initial_data
app = FastAPI(title="宇之然内容创作平台", version="0.1.0")
logger = logging.getLogger(__name__)
# CORS - 生产环境应限制 origins
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: 生产环境改为具体域名
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -26,40 +27,45 @@ app.add_middleware(
# 初始化数据库
Base.metadata.create_all(bind=engine)
init_db()
import_topics_from_json() # 首次自动导入
import_initial_data()
# 注册路由
# 注册 API 路由
app.include_router(topics.router)
app.include_router(system.router)
app.include_router(articles.router)
app.include_router(publisher.router)
app.include_router(publishing.router)
app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(audit.router)
# 挂载前端静态文件
# 挂载前端
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
STATIC_DIR = FRONTEND_DIR / "static"
# 检查前端文件是否存在,若不存在下载Element Plus等依赖
if not FRONTEND_DIR.exists():
FRONTEND_DIR.mkdir(parents=True, exist_ok=True)
logger = logging.getLogger(__name__)
logger.warning(f"Frontend dir not found: {FRONTEND_DIR}, will serve API only")
# 默认静态文件服务(若前端存在)
if FRONTEND_DIR.exists() and (FRONTEND_DIR / "index.html").exists():
app.mount("/", StaticFiles(directory=str(FRONTEND_DIR), html=True), name="frontend")
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
logging.getLogger(__name__).info(f"Frontend mounted at / from {FRONTEND_DIR}")
logger.info(f"Frontend mounted at / from {FRONTEND_DIR}")
# SW 特殊处理
sw_path = FRONTEND_DIR / "sw.js"
if sw_path.exists():
@app.get("/sw.js")
async def service_worker():
return FileResponse(
sw_path,
media_type="application/javascript",
headers={"Cache-Control": "no-cache", "Service-Worker-Allowed": "/"}
)
# 离线页面
offline_path = FRONTEND_DIR / "offline.html"
if offline_path.exists():
@app.get("/offline.html")
async def offline_page():
return FileResponse(offline_path, media_type="text/html")
else:
@app.get("/")
def root():
return {
"service": "宇之然内容创作平台 API",
"version": "0.1.0",
"docs": "/docs",
"frontend_missing": str(FRONTEND_DIR)
}
return {"service": "API only", "docs": "/docs"}
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
uvicorn.run("app.main:app", host="0.0.0.0", port=8001)
+71
View File
@@ -3,6 +3,55 @@ from sqlalchemy.sql import func
from .database import Base
from datetime import datetime
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
created_at = Column(DateTime(timezone=True), server_default=func.now())
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"username": self.username,
"action": self.action,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"details": self.details or {},
"ip_address": self.ip_address,
"user_agent": self.user_agent,
"created_at": self.created_at.isoformat() if self.created_at else None
}
class User(Base):
__tablename__ = "users"
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
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,
"username": self.username,
"role": self.role,
"created_at": self.created_at.isoformat() if self.created_at else None
}
class Topic(Base):
__tablename__ = "topics"
@@ -21,11 +70,13 @@ class Topic(Base):
source_file = Column(String)
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) # 选题创作完成时间
ready_at = Column(Date)
published_at = Column(Date)
compliance_score = Column(Integer)
platform_urls = Column(JSON, default=dict) # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
class Article(Base):
__tablename__ = "articles"
@@ -37,3 +88,23 @@ class Article(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
compliance_score = Column(Integer)
html_content = Column(Text) # 可缓存HTML内容以便预览
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) # 错误信息
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# 关联查询
# 可以添加外键关联到 Topic, 但这里保持简单
+93 -9
View File
@@ -1,7 +1,51 @@
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
from datetime import datetime, date
from typing import Optional, List, Dict, Any
class AuditLogBase(BaseModel):
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: Optional[datetime] = None
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
@@ -11,14 +55,14 @@ class TopicBase(BaseModel):
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
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class ArticleBase(BaseModel):
id: str
@@ -30,8 +74,7 @@ class ArticleBase(BaseModel):
created_at: Optional[datetime] = None
class ArticleResponse(ArticleBase):
class Config:
from_attributes = True
model_config = ConfigDict(from_attributes=True)
class SystemStatus(BaseModel):
total_topics: int
@@ -40,14 +83,55 @@ class SystemStatus(BaseModel):
today_articles: int
compliance_rate: float
last_optimization: Optional[datetime] = None
execution_time: Optional[float] = None # 任务执行耗时(秒)
execution_time: Optional[float] = None
class OptimizationRequest(BaseModel):
topic_ids: Optional[List[str]] = None # None表示全部
topic_ids: Optional[List[str]] = None
class PublishRequest(BaseModel):
topic_id: str
platform_urls: Dict[str, str] # {"zhihu": "...", "wechat": "...", "xiaohongshu": "..."}
platform_urls: Dict[str, str]
class PublishActionRequest(BaseModel):
action: str
platform: Optional[str] = None
status: str
operator: Optional[str] = None
description: Optional[str] = None
suggestion: Optional[str] = None
url: Optional[str] = None
error_msg: Optional[str] = None
class PublishRecordResponse(BaseModel):
id: int
topic_id: str
platform: str
action: str
status: str
operator: Optional[str] = None
description: Optional[str] = None
suggestion: Optional[str] = None
url: Optional[str] = None
error_msg: Optional[str] = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class BatchPublishRequest(BaseModel):
date: str # YYYY-MM-DD
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)