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
+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