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:
@@ -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": "删除成功"}
|
||||
@@ -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]]
|
||||
@@ -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
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, 但这里保持简单
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
pydantic==2.9.2
|
||||
sqlalchemy==2.0.36
|
||||
python-multipart==0.0.9
|
||||
jinja2==3.1.5
|
||||
aiofiles==24.1.0
|
||||
python-dateutil==2.9.0.post0
|
||||
pytz==2024.2
|
||||
PyYAML>=6.0
|
||||
feedparser>=6.0
|
||||
requests>=2.32.0
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
psycopg2-binary==2.9.9
|
||||
pydantic==2.5.0
|
||||
python-dotenv==1.0.0
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
asyncpg==0.29.0
|
||||
alembic==1.12.1
|
||||
gunicorn==21.2.0
|
||||
prometheus-client==0.19.0
|
||||
Reference in New Issue
Block a user