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"}