【奥赛AI平台】(3):macOS上构建FastAPI后端核心

11 阅读22分钟

准备打造后端引擎了 🔧

🎯 今日目标

  1. 搭建FastAPI项目结构(macOS特化配置)
  2. 创建SQLAlchemy ORM模型(与Day 2的表结构对应)
  3. 实现JWT用户认证系统(注册/登录/鉴权)
  4. 创建题目的CRUD API(增删改查)
  5. 配置macOS开发环境(热重载、调试、API文档)

1. 进入虚拟环境并创建后端项目

# 进入项目目录
$ cd ~/Projects/math-olympiad

# 激活Python虚拟环境
$ source venv/bin/activate
(venv) $ python --version
Python 3.11.4

# 创建后端项目结构(扩展昨天的设计)
(venv) $ mkdir -p backend/{app/{api,routes,core,models,schemas,services,crud,dependencies},alembic/versions,tests}
(venv) $ mkdir -p logs/{api,error}

2. 创建macOS特化的requirements.txt

(venv) $ cat > backend/requirements.txt << 'EOF'
# FastAPI核心
fastapi==0.104.1
uvicorn[standard]==0.24.0

# 数据库
sqlalchemy==2.0.23
psycopg2-binary==2.9.9
alembic==1.12.1
asyncpg==0.29.0

# 认证和安全性
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6

# 环境配置
python-dotenv==1.0.0
pydantic-settings==2.1.0

# 开发工具(macOS特化)
watchfiles==0.21.0      # 文件监控,实现热重载
rich==13.7.0           # 漂亮的终端输出
ipython==8.17.2        # 交互式Python(macOS开发友好)
debugpy==1.8.0         # VS Code调试支持

# 数据验证
pydantic==2.5.0
email-validator==2.1.0

# 实用工具
python-dateutil==2.8.2
python-slugify==8.0.1

# 测试(开发环境)
pytest==7.4.3
pytest-asyncio==0.21.1
httpx==0.25.2
EOF

# 安装依赖(使用清华镜像加速)
(venv) $ pip install -r backend/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple

# 验证关键包安装
(venv) $ python -c "import fastapi, sqlalchemy, pydantic; print(f'✅ FastAPI {fastapi.__version__}, SQLAlchemy {sqlalchemy.__version__}, Pydantic {pydantic.__version__}')"
✅ FastAPI 0.104.1, SQLAlchemy 2.0.23, Pydantic 2.5.0

#!!!当安装的某个具体包有问题的时候 根据情况单独处理
比如asyncpg

# 方法1:使用 Homebrew 预编译版本
brew install libpq openssl
# 设置环境变量
export LDFLAGS="-L$(brew --prefix openssl)/lib"
export CPPFLAGS="-I$(brew --prefix openssl)/include"
# 然后安装
pip install asyncpg

3. 创建macOS特化的配置文件系统

(venv) $ cat > backend/app/core/config.py << 'EOF'
"""
macOS特化的配置文件系统
使用Pydantic Settings管理环境变量
"""
from typing import Optional, List
from pydantic_settings import BaseSettings
from pydantic import PostgresDsn, validator, field_validator
import secrets
from pathlib import Path

class Settings(BaseSettings):
    """应用配置"""
    
    # 基础配置
    PROJECT_NAME: str = "Math Olympiad AI Platform"
    VERSION: str = "1.0.0"
    API_V1_STR: str = "/api/v1"
    
    # 安全配置
    SECRET_KEY: str = secrets.token_urlsafe(32)
    ALGORITHM: str = "HS256"
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 7  # 7天
    
    # CORS配置
    BACKEND_CORS_ORIGINS: List[str] = [
        "http://localhost:5173",  # Vue前端
        "http://127.0.0.1:5173",
        "http://localhost:8000",
    ]
    
    # 数据库配置
    POSTGRES_SERVER: str = "localhost"
    POSTGRES_USER: str = "admin"
    POSTGRES_PASSWORD: str = "olympiad123"
    POSTGRES_DB: str = "olympiad"
    POSTGRES_PORT: str = "5432"
    
    # 构建数据库URL
    SQLALCHEMY_DATABASE_URL: Optional[PostgresDsn] = None
    
    @field_validator("SQLALCHEMY_DATABASE_URL", mode="before")
    @classmethod
    def assemble_db_connection(cls, v: Optional[str], info):
        """构建数据库连接字符串"""
        if isinstance(v, str):
            return v
        
        values = info.data
        return PostgresDsn.build(
            scheme="postgresql+psycopg2",
            username=values.get("POSTGRES_USER"),
            password=values.get("POSTGRES_PASSWORD"),
            host=values.get("POSTGRES_SERVER"),
            port=int(values.get("POSTGRES_PORT")),
            path=f"{values.get('POSTGRES_DB') or ''}",
        )
    
    # Redis配置
    REDIS_HOST: str = "localhost"
    REDIS_PORT: int = 6379
    REDIS_PASSWORD: str = "redis123"
    REDIS_DB: int = 0
    
    # macOS特化配置
    MACOS_DEV_MODE: bool = True
    HOT_RELOAD: bool = True
    DEBUG_PORT: int = 5678  # VS Code调试端口
    
    # 文件上传配置
    UPLOAD_DIR: Path = Path("uploads")
    MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024  # 10MB
    
    # 日志配置
    LOG_LEVEL: str = "DEBUG"
    LOG_FILE: Path = Path("logs/backend.log")
    
    # 应用行为
    FIRST_SUPERUSER: str = "admin"
    FIRST_SUPERUSER_PASSWORD: str = "admin123"
    USERS_OPEN_REGISTRATION: bool = True
    
    class Config:
        case_sensitive = True
        env_file = ".env"
        env_file_encoding = "utf-8"

# 全局配置实例
settings = Settings()

# macOS特化的开发配置检查
if settings.MACOS_DEV_MODE:
    print(f"🚀 {settings.PROJECT_NAME} v{settings.VERSION}")
    print(f"💻 macOS开发模式已启用")
    print(f"🔗 数据库: {settings.POSTGRES_SERVER}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}")
    print(f"⚡ 热重载: {'已启用' if settings.HOT_RELOAD else '已禁用'}")
EOF

4. 创建macOS特化的日志配置

(venv) $ cat > backend/app/core/logging_config.py << 'EOF'
"""
macOS特化的日志配置
彩色输出,文件日志,结构化日志
"""
import logging
import sys
from pathlib import Path
from logging.handlers import RotatingFileHandler
from typing import Optional
from app.core.config import settings

class ColorFormatter(logging.Formatter):
    """macOS终端彩色日志格式化器"""
    
    COLORS = {
        'DEBUG': '\033[94m',    # 蓝色
        'INFO': '\033[92m',     # 绿色
        'WARNING': '\033[93m',  # 黄色
        'ERROR': '\033[91m',    # 红色
        'CRITICAL': '\033[95m', # 紫色
        'RESET': '\033[0m',     # 重置
    }
    
    def format(self, record):
        # 添加颜色
        color = self.COLORS.get(record.levelname, self.COLORS['RESET'])
        record.levelname = f"{color}{record.levelname}{self.COLORS['RESET']}"
        record.name = f"\033[90m{record.name}{self.COLORS['RESET']}"
        return super().format(record)

def setup_logging(log_file: Optional[Path] = None):
    """配置日志系统"""
    
    # 确保日志目录存在
    if log_file:
        log_file.parent.mkdir(parents=True, exist_ok=True)
    
    # 获取根日志器
    logger = logging.getLogger()
    logger.setLevel(getattr(logging, settings.LOG_LEVEL))
    
    # 清除现有处理器
    logger.handlers.clear()
    
    # 控制台处理器(彩色输出)
    console_handler = logging.StreamHandler(sys.stdout)
    console_format = ColorFormatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    console_handler.setFormatter(console_format)
    logger.addHandler(console_handler)
    
    # 文件处理器(JSON格式,便于分析)
    if log_file:
        file_handler = RotatingFileHandler(
            log_file,
            maxBytes=10 * 1024 * 1024,  # 10MB
            backupCount=5,
            encoding='utf-8'
        )
        file_format = logging.Formatter(
            '{"time": "%(asctime)s", "name": "%(name)s", '
            '"level": "%(levelname)s", "message": "%(message)s", '
            '"module": "%(module)s", "func": "%(funcName)s", "line": %(lineno)d}',
            datefmt='%Y-%m-%d %H:%M:%S'
        )
        file_handler.setFormatter(file_format)
        logger.addHandler(file_handler)
    
    # SQLAlchemy日志(开发时查看SQL)
    sqlalchemy_logger = logging.getLogger('sqlalchemy.engine')
    sqlalchemy_logger.setLevel(logging.WARNING)  # 开发时可设为INFO查看SQL
    
    # Uvicorn访问日志
    uvicorn_access = logging.getLogger("uvicorn.access")
    uvicorn_access.setLevel(logging.INFO)
    
    # Uvicorn错误日志
    uvicorn_error = logging.getLogger("uvicorn.error")
    uvicorn_error.setLevel(logging.INFO)
    
    logger.info(f"✅ 日志系统初始化完成,级别: {settings.LOG_LEVEL}")
    if log_file:
        logger.info(f"📝 日志文件: {log_file.absolute()}")
    
    return logger

# 全局日志器
logger = setup_logging(settings.LOG_FILE)
EOF

上午 10:30-12:00 | 数据库连接与ORM模型

5. 创建数据库连接和会话管理

(venv) $ cat > backend/app/core/database.py << 'EOF'
"""
macOS特化的数据库连接管理
使用SQLAlchemy 2.0+异步API
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import QueuePool
from contextlib import contextmanager
import threading
import time
from typing import Generator

from app.core.config import settings
from app.core.logging_config import logger

# 创建数据库引擎(macOS特化:使用连接池提高性能)
engine = create_engine(
    str(settings.SQLALCHEMY_DATABASE_URL),
    poolclass=QueuePool,  # 连接池
    pool_size=20,         # 连接池大小
    max_overflow=30,      # 最大溢出连接
    pool_pre_ping=True,   # 连接前ping,防止连接失效
    pool_recycle=3600,    # 1小时后回收连接
    echo=False,           # 开发时可设为True查看SQL
    echo_pool=False,      # 连接池日志
)

# 创建会话工厂
SessionLocal = sessionmaker(
    autocommit=False,
    autoflush=False,
    bind=engine,
    class_=Session,
    expire_on_commit=False,  # macOS开发优化
)

# 声明基类
Base = declarative_base()

def get_db() -> Generator[Session, None, None]:
    """
    获取数据库会话(依赖注入)
    macOS特化:添加连接监控
    """
    db = SessionLocal()
    start_time = time.time()
    
    try:
        yield db
        db.commit()
    except Exception as e:
        db.rollback()
        logger.error(f"数据库操作失败: {e}", exc_info=True)
        raise
    finally:
        db.close()
        # macOS开发监控:记录慢查询
        elapsed = time.time() - start_time
        if elapsed > 1.0:  # 超过1秒的查询
            logger.warning(f"⏰ 慢数据库会话: {elapsed:.2f}秒")

@contextmanager
def db_context():
    """
    上下文管理器方式使用数据库
    macOS开发友好:自动资源管理
    """
    db = SessionLocal()
    try:
        yield db
        db.commit()
    except Exception as e:
        db.rollback()
        raise
    finally:
        db.close()

def init_db() -> None:
    """
    初始化数据库(创建所有表)
    macOS特化:检查Apple Silicon兼容性
    """
    import platform
    
    logger.info("🔄 开始初始化数据库...")
    logger.info(f"💻 系统架构: {platform.machine()}")
    
    try:
        # 导入所有模型,确保SQLAlchemy知道它们
        from app.models import user, problem, knowledge_point  # noqa
        
        # 创建所有表
        Base.metadata.create_all(bind=engine)
        
        logger.info("✅ 数据库表创建成功!")
        
        # 验证数据库连接
        with engine.connect() as conn:
            result = conn.execute("SELECT version();")
            db_version = result.fetchone()[0]
            logger.info(f"📊 数据库版本: {db_version}")
            
            # 检查表数量
            result = conn.execute("""
                SELECT COUNT(*) 
                FROM information_schema.tables 
                WHERE table_schema = 'public'
            """)
            table_count = result.fetchone()[0]
            logger.info(f"📈 数据表数量: {table_count}")
            
    except Exception as e:
        logger.error(f"❌ 数据库初始化失败: {e}", exc_info=True)
        raise

# macOS开发助手:连接池监控
class ConnectionPoolMonitor(threading.Thread):
    """监控数据库连接池状态"""
    
    def __init__(self, engine, interval=60):
        super().__init__(daemon=True)
        self.engine = engine
        self.interval = interval
        self.running = True
        
    def run(self):
        logger.info("🔍 数据库连接池监控已启动")
        while self.running:
            try:
                pool = self.engine.pool
                logger.debug(
                    f"连接池状态: 使用中={pool.checkedin()}, "
                    f"空闲={pool.checkedout()}, "
                    f"总大小={pool.size()}"
                )
            except Exception as e:
                logger.warning(f"连接池监控错误: {e}")
            
            time.sleep(self.interval)
    
    def stop(self):
        self.running = False

# macOS开发环境:启动连接池监控
if settings.MACOS_DEV_MODE:
    pool_monitor = ConnectionPoolMonitor(engine)
    pool_monitor.start()
    logger.info("👁️  数据库连接池监控已启用")
EOF

6. 创建ORM模型(对应Day 2的表结构)

先创建用户模型:

(venv) $ cat > backend/app/models/user.py << 'EOF'
"""
用户模型
对应Day 2的users表设计
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, JSON, Text, func
from sqlalchemy.sql import expression
from datetime import datetime, timezone
import uuid

from app.core.database import Base

class User(Base):
    """用户表模型"""
    
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True, index=True)
    
    # 账户信息
    username = Column(String(50), unique=True, index=True, nullable=False)
    email = Column(String(255), unique=True, index=True, nullable=True)
    hashed_password = Column(String(255), nullable=False)
    full_name = Column(String(100), nullable=True)
    
    # 角色和状态
    role = Column(String(20), default="student", nullable=False)
    grade = Column(String(20), nullable=True)  # 年级
    school = Column(String(100), nullable=True)  # 学校
    
    is_active = Column(Boolean, default=True, server_default=expression.true())
    is_verified = Column(Boolean, default=False, server_default=expression.false())
    
    # 时间戳
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    last_login_at = Column(DateTime(timezone=True), nullable=True)
    
    # 元数据
    metadata = Column(JSON, default=dict, server_default="{}")
    
    def __repr__(self):
        return f"<User(id={self.id}, username={self.username}, role={self.role})>"
    
    @property
    def is_admin(self):
        """检查是否是管理员"""
        return self.role == "admin"
    
    @property
    def is_student(self):
        """检查是否是学生"""
        return self.role == "student"
    
    @property
    def is_teacher(self):
        """检查是否是老师"""
        return self.role == "teacher"
    
    def update_last_login(self):
        """更新最后登录时间"""
        self.last_login_at = datetime.now(timezone.utc)
    
    def to_dict(self, include_sensitive=False):
        """转换为字典(用于API响应)"""
        data = {
            "id": self.id,
            "username": self.username,
            "email": self.email,
            "full_name": self.full_name,
            "role": self.role,
            "grade": self.grade,
            "school": self.school,
            "is_active": self.is_active,
            "is_verified": self.is_verified,
            "created_at": self.created_at.isoformat() if self.created_at else None,
            "last_login_at": self.last_login_at.isoformat() if self.last_login_at else None,
        }
        
        if include_sensitive:
            data["metadata"] = self.metadata
            
        return data
EOF

创建知识点模型:

(venv) $ cat > backend/app/models/knowledge_point.py << 'EOF'
"""
知识点模型
对应Day 2的knowledge_points表设计
支持树形结构
"""
from sqlalchemy import Column, Integer, String, Text, Float, DateTime, func, ForeignKey
from sqlalchemy.orm import relationship, validates
from sqlalchemy.ext.hybrid import hybrid_property

from app.core.database import Base

class KnowledgePoint(Base):
    """知识点表模型(树形结构)"""
    
    __tablename__ = "knowledge_points"
    
    id = Column(Integer, primary_key=True, index=True)
    
    # 知识点信息
    name = Column(String(100), nullable=False, index=True)
    code = Column(String(50), unique=True, nullable=False, index=True)
    
    # 树形结构
    parent_id = Column(Integer, ForeignKey("knowledge_points.id", ondelete="CASCADE"), nullable=True)
    level = Column(Integer, default=1, nullable=False)  # 层级
    
    # 描述和排序
    description = Column(Text, nullable=True)
    sort_order = Column(Integer, default=0)
    weight = Column(Float, default=1.0)  # 权重
    
    # 统计信息
    problem_count = Column(Integer, default=0)
    
    # 时间戳
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    
    # 关系
    parent = relationship(
        "KnowledgePoint", 
        remote_side=[id],
        backref="children",
        lazy="select",
    )
    
    def __repr__(self):
        return f"<KnowledgePoint(id={self.id}, code={self.code}, name={self.name})>"
    
    @validates('code')
    def validate_code(self, key, code):
        """验证知识点编码格式"""
        if not code.replace('.', '').replace('_', '').isalnum():
            raise ValueError("知识点编码只能包含字母、数字、点和下划线")
        return code
    
    @hybrid_property
    def full_path(self):
        """获取完整路径(如:algebra.equation.quadratic)"""
        if self.parent:
            return f"{self.parent.full_path}.{self.code}"
        return self.code
    
    def to_dict(self, include_children=False, max_depth=2):
        """转换为字典"""
        data = {
            "id": self.id,
            "name": self.name,
            "code": self.code,
            "parent_id": self.parent_id,
            "level": self.level,
            "description": self.description,
            "weight": self.weight,
            "problem_count": self.problem_count,
            "full_path": self.full_path,
            "created_at": self.created_at.isoformat() if self.created_at else None,
        }
        
        if include_children and max_depth > 0 and self.children:
            data["children"] = [
                child.to_dict(include_children=True, max_depth=max_depth-1)
                for child in self.children
            ]
        
        return data
    
    def get_ancestors(self):
        """获取所有祖先节点"""
        ancestors = []
        current = self.parent
        while current:
            ancestors.insert(0, current.to_dict())
            current = current.parent
        return ancestors
    
    def get_descendants(self, include_self=False):
        """获取所有后代节点"""
        descendants = []
        
        if include_self:
            descendants.append(self.to_dict())
        
        for child in self.children:
            descendants.append(child.to_dict(include_children=False))
            descendants.extend(child.get_descendants())
        
        return descendants
EOF

创建题目模型:

(venv) $ cat > backend/app/models/problem.py << 'EOF'
"""
题目模型
对应Day 2的problems表设计
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, Float, DateTime, JSON, ForeignKey, func, Index
from sqlalchemy.orm import relationship, validates
from sqlalchemy.sql import expression
import json

from app.core.database import Base

class Problem(Base):
    """题目表模型"""
    
    __tablename__ = "problems"
    
    id = Column(Integer, primary_key=True, index=True)
    
    # 题目基本信息
    title = Column(String(200), nullable=False, index=True)
    content = Column(Text, nullable=False)
    content_type = Column(String(20), default="text", nullable=False)  # text, markdown, latex
    
    # 选项和答案
    options = Column(JSON, nullable=False, default=dict)
    correct_answer = Column(String(10), nullable=False)
    solution = Column(Text, nullable=True)
    solution_type = Column(String(20), default="text", nullable=True)
    
    # 分类和难度
    difficulty = Column(Integer, default=3, nullable=False)  # 1-5
    source_type = Column(String(50), nullable=True)  # AMC8, 迎春杯, 华杯赛
    source_year = Column(Integer, nullable=True)
    source_detail = Column(String(100), nullable=True)
    
    # 预估属性
    estimated_time = Column(Integer, nullable=True)  # 秒
    success_rate = Column(Float, nullable=True)  # 历史正确率
    
    # 状态控制
    is_published = Column(Boolean, default=False, server_default=expression.false())
    is_deleted = Column(Boolean, default=False, server_default=expression.false())
    
    # 审核信息
    reviewed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
    reviewed_at = Column(DateTime(timezone=True), nullable=True)
    review_status = Column(String(20), default="pending", nullable=False)
    
    # 统计信息
    total_attempts = Column(Integer, default=0)
    correct_attempts = Column(Integer, default=0)
    
    # 创建者
    created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
    
    # 时间戳
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
    
    # 关系
    creator = relationship("User", foreign_keys=[created_by], lazy="select")
    reviewer = relationship("User", foreign_keys=[reviewed_by], lazy="select")
    knowledge_points = relationship(
        "KnowledgePoint",
        secondary="problem_knowledge_points",
        backref="problems",
        lazy="select",
    )
    
    # 全文搜索索引(在数据库层面实现)
    __table_args__ = (
        Index('ix_problems_search', 'title', 'content', postgresql_using='gin'),
    )
    
    def __repr__(self):
        return f"<Problem(id={self.id}, title={self.title}, difficulty={self.difficulty})>"
    
    @validates('options')
    def validate_options(self, key, options):
        """验证选项格式"""
        if isinstance(options, str):
            try:
                options = json.loads(options)
            except json.JSONDecodeError:
                raise ValueError("options必须是有效的JSON字符串")
        
        if not isinstance(options, dict):
            raise ValueError("options必须是字典格式")
        
        # 确保有A-D选项
        for option in ['A', 'B', 'C', 'D']:
            if option not in options:
                raise ValueError(f"缺少选项{option}")
        
        return options
    
    @validates('difficulty')
    def validate_difficulty(self, key, difficulty):
        """验证难度范围"""
        if not 1 <= difficulty <= 5:
            raise ValueError("难度必须在1-5之间")
        return difficulty
    
    @property
    def accuracy_rate(self):
        """计算正确率"""
        if self.total_attempts == 0:
            return 0.0
        return self.correct_attempts / self.total_attempts * 100
    
    def to_dict(self, include_solution=False, include_creator=False):
        """转换为字典"""
        data = {
            "id": self.id,
            "title": self.title,
            "content": self.content,
            "content_type": self.content_type,
            "options": self.options,
            "correct_answer": self.correct_answer,
            "difficulty": self.difficulty,
            "source_type": self.source_type,
            "source_year": self.source_year,
            "estimated_time": self.estimated_time,
            "is_published": self.is_published,
            "review_status": self.review_status,
            "total_attempts": self.total_attempts,
            "correct_attempts": self.correct_attempts,
            "accuracy_rate": round(self.accuracy_rate, 2),
            "created_at": self.created_at.isoformat() if self.created_at else None,
            "updated_at": self.updated_at.isoformat() if self.updated_at else None,
        }
        
        if include_solution and self.solution:
            data["solution"] = self.solution
            data["solution_type"] = self.solution_type
        
        if include_creator and self.creator:
            data["creator"] = {
                "id": self.creator.id,
                "username": self.creator.username,
                "full_name": self.creator.full_name,
            }
        
        if self.knowledge_points:
            data["knowledge_points"] = [
                {"id": kp.id, "name": kp.name, "code": kp.code}
                for kp in self.knowledge_points
            ]
        
        return data
    
    def increment_attempts(self, is_correct: bool):
        """增加答题统计"""
        self.total_attempts += 1
        if is_correct:
            self.correct_attempts += 1

# 题目-知识点关联表(多对多)
class ProblemKnowledgePoint(Base):
    """题目-知识点关联表"""
    
    __tablename__ = "problem_knowledge_points"
    
    problem_id = Column(Integer, ForeignKey("problems.id", ondelete="CASCADE"), primary_key=True)
    knowledge_point_id = Column(Integer, ForeignKey("knowledge_points.id", ondelete="CASCADE"), primary_key=True)
    is_primary = Column(Boolean, default=False)
    weight = Column(Float, default=1.0)
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    
    # 关系
    problem = relationship("Problem", backref="problem_knowledge_associations", lazy="select")
    knowledge_point = relationship("KnowledgePoint", backref="knowledge_point_problem_associations", lazy="select")
    
    def __repr__(self):
        return f"<ProblemKnowledgePoint(problem={self.problem_id}, knowledge={self.knowledge_point_id})>"
EOF

创建练习和答题模型:

(venv) $ cat > backend/app/models/practice.py << 'EOF'
"""
练习和答题模型
对应Day 2的practice_sessions, answer_records等表设计
"""
from sqlalchemy import Column, Integer, String, Boolean, Float, DateTime, JSON, ForeignKey, func, ARRAY
from sqlalchemy.orm import relationship, validates
from sqlalchemy.dialects.postgresql import INET
from datetime import datetime, timezone

from app.core.database import Base

class PracticeSession(Base):
    """练习会话表模型"""
    
    __tablename__ = "practice_sessions"
    
    id = Column(Integer, primary_key=True, index=True)
    user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    
    # 练习配置
    session_type = Column(String(50), default="random", nullable=False)  # random, knowledge_point, difficulty, exam
    config = Column(JSON, default=dict, server_default="{}")
    
    # 状态
    status = Column(String(20), default="in_progress", nullable=False)  # in_progress, completed, abandoned
    
    # 统计信息
    total_questions = Column(Integer, default=0)
    completed_questions = Column(Integer, default=0)
    correct_questions = Column(Integer, default=0)
    
    # 时间跟踪
    started_at = Column(DateTime(timezone=True), server_default=func.now())
    completed_at = Column(DateTime(timezone=True), nullable=True)
    total_duration = Column(Integer, nullable=True)  # 秒
    
    # 性能指标
    average_time_per_question = Column(Float, nullable=True)
    accuracy_rate = Column(Float, nullable=True)
    
    # 元数据
    device_info = Column(JSON, default=dict, server_default="{}")
    ip_address = Column(INET, nullable=True)
    
    # 关系
    user = relationship("User", backref="practice_sessions", lazy="select")
    answer_records = relationship("AnswerRecord", backref="session", cascade="all, delete-orphan", lazy="select")
    
    def __repr__(self):
        return f"<PracticeSession(id={self.id}, user={self.user_id}, status={self.status})>"
    
    @property
    def is_completed(self):
        """检查是否完成"""
        return self.status == "completed"
    
    @property
    def completion_rate(self):
        """计算完成率"""
        if self.total_questions == 0:
            return 0.0
        return self.completed_questions / self.total_questions * 100
    
    def complete_session(self):
        """完成练习会话"""
        if not self.is_completed:
            self.status = "completed"
            self.completed_at = datetime.now(timezone.utc)
            
            # 计算总时长
            if self.started_at and self.completed_at:
                self.total_duration = int((self.completed_at - self.started_at).total_seconds())
            
            # 计算平均每题用时
            if self.completed_questions > 0 and self.total_duration:
                self.average_time_per_question = self.total_duration / self.completed_questions
            
            # 计算正确率
            if self.completed_questions > 0:
                self.accuracy_rate = self.correct_questions / self.completed_questions * 100
    
    def to_dict(self, include_answers=False):
        """转换为字典"""
        data = {
            "id": self.id,
            "user_id": self.user_id,
            "session_type": self.session_type,
            "status": self.status,
            "total_questions": self.total_questions,
            "completed_questions": self.completed_questions,
            "correct_questions": self.correct_questions,
            "completion_rate": round(self.completion_rate, 2),
            "started_at": self.started_at.isoformat() if self.started_at else None,
            "completed_at": self.completed_at.isoformat() if self.completed_at else None,
            "total_duration": self.total_duration,
            "average_time_per_question": round(self.average_time_per_question, 2) if self.average_time_per_question else None,
            "accuracy_rate": round(self.accuracy_rate, 2) if self.accuracy_rate else None,
            "config": self.config,
        }
        
        if include_answers and self.answer_records:
            data["answer_records"] = [record.to_dict() for record in self.answer_records]
        
        return data

class AnswerRecord(Base):
    """答题记录表模型"""
    
    __tablename__ = "answer_records"
    
    id = Column(Integer, primary_key=True, index=True)
    session_id = Column(Integer, ForeignKey("practice_sessions.id", ondelete="CASCADE"), nullable=False)
    problem_id = Column(Integer, ForeignKey("problems.id"), nullable=False)
    user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
    
    # 答题详情
    user_answer = Column(String(10), nullable=True)
    is_correct = Column(Boolean, nullable=True)
    confidence_level = Column(Integer, nullable=True)  # 1-5
    
    # 时间分析
    time_spent = Column(Integer, nullable=False)  # 秒
    first_response_time = Column(Integer, nullable=True)  # 秒
    review_count = Column(Integer, default=0)
    
    # 步骤数据
    steps = Column(JSON, default=list, server_default="[]")
    hesitation_points = Column(JSON, default=list, server_default="[]")
    
    # 反馈
    user_feedback = Column(String(20), nullable=True)  # too_easy, appropriate, too_hard, unclear
    note = Column(String(500), nullable=True)
    
    # 时间戳
    answered_at = Column(DateTime(timezone=True), server_default=func.now())
    created_at = Column(DateTime(timezone=True), server_default=func.now())
    
    # 知识点数组(优化查询)
    knowledge_point_ids = Column(ARRAY(Integer), default=[], server_default="{}")
    
    # 关系
    problem = relationship("Problem", backref="answer_records", lazy="select")
    user = relationship("User", backref="answer_records", lazy="select")
    
    def __repr__(self):
        return f"<AnswerRecord(id={self.id}, session={self.session_id}, correct={self.is_correct})>"
    
    def to_dict(self, include_problem=False):
        """转换为字典"""
        data = {
            "id": self.id,
            "session_id": self.session_id,
            "problem_id": self.problem_id,
            "user_answer": self.user_answer,
            "is_correct": self.is_correct,
            "confidence_level": self.confidence_level,
            "time_spent": self.time_spent,
            "first_response_time": self.first_response_time,
            "review_count": self.review_count,
            "user_feedback": self.user_feedback,
            "note": self.note,
            "answered_at": self.answered_at.isoformat() if self.answered_at else None,
            "knowledge_point_ids": self.knowledge_point_ids,
        }
        
        if include_problem and self.problem:
            data["problem"] = {
                "id": self.problem.id,
                "title": self.problem.title,
                "difficulty": self.problem.difficulty,
                "options": self.problem.options,
                "correct_answer": self.problem.correct_answer,
            }
        
        return data

# 错题本模型(后面创建)
EOF

下午 13:30-15:00 | 用户认证系统

7. 创建Pydantic模式(数据验证)

(venv) $ cat > backend/app/schemas/user.py << 'EOF'
"""
用户相关的Pydantic模式
用于API请求/响应验证
"""
from typing import Optional, List
from pydantic import BaseModel, EmailStr, validator, constr
from datetime import datetime

# 基础模式
class UserBase(BaseModel):
    """用户基础模式"""
    username: constr(min_length=3, max_length=50, pattern=r'^[a-zA-Z0-9_]+$')
    email: Optional[EmailStr] = None
    full_name: Optional[str] = None
    role: Optional[str] = "student"
    grade: Optional[str] = None
    school: Optional[str] = None

# 创建用户
class UserCreate(UserBase):
    """创建用户模式"""
    password: constr(min_length=6, max_length=100)
    
    @validator('role')
    def validate_role(cls, v):
        allowed_roles = ['student', 'teacher', 'admin', 'parent']
        if v not in allowed_roles:
            raise ValueError(f'角色必须是: {", ".join(allowed_roles)}')
        return v

# 更新用户
class UserUpdate(BaseModel):
    """更新用户模式"""
    email: Optional[EmailStr] = None
    full_name: Optional[str] = None
    grade: Optional[str] = None
    school: Optional[str] = None
    is_active: Optional[bool] = None
    metadata: Optional[dict] = None

# 用户响应
class UserResponse(UserBase):
    """用户响应模式"""
    id: int
    is_active: bool
    is_verified: bool
    created_at: datetime
    last_login_at: Optional[datetime] = None
    
    class Config:
        from_attributes = True

# 登录
class UserLogin(BaseModel):
    """用户登录模式"""
    username: str
    password: str

# Token响应
class Token(BaseModel):
    """Token响应模式"""
    access_token: str
    token_type: str = "bearer"
    expires_in: int

# Token数据
class TokenData(BaseModel):
    """Token数据模式"""
    username: Optional[str] = None
    user_id: Optional[int] = None
    role: Optional[str] = None

# 修改密码
class ChangePassword(BaseModel):
    """修改密码模式"""
    current_password: str
    new_password: constr(min_length=6, max_length=100)

# 用户统计
class UserStats(BaseModel):
    """用户统计模式"""
    total_practice_sessions: int
    total_questions_attempted: int
    overall_accuracy: float
    total_practice_time: int  # 秒
    average_session_duration: float

# 用户详情(包含统计)
class UserDetail(UserResponse):
    """用户详情模式"""
    stats: Optional[UserStats] = None
EOF

8. 创建密码加密和JWT工具

(venv) $ cat > backend/app/core/security.py << 'EOF'
"""
macOS特化的安全工具
密码加密和JWT令牌处理
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status

from app.core.config import settings
from app.schemas.user import TokenData

# 密码加密上下文
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# macOS特化:检查安全设置
def check_macos_security():
    """检查macOS安全设置(开发环境提醒)"""
    import subprocess
    import platform
    
    if platform.system() == "Darwin":
        try:
            # 检查Gatekeeper设置
            result = subprocess.run(
                ["spctl", "--status"],
                capture_output=True,
                text=True
            )
            if "assessments enabled" in result.stdout:
                print("🔒 macOS Gatekeeper已启用 - 安全设置正常")
            else:
                print("⚠️  macOS Gatekeeper未启用 - 建议启用安全设置")
        except Exception:
            pass

check_macos_security()

def verify_password(plain_password: str, hashed_password: str) -> bool:
    """验证密码"""
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password: str) -> str:
    """生成密码哈希"""
    return pwd_context.hash(password)

def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
    """创建JWT访问令牌"""
    to_encode = data.copy()
    
    # 设置过期时间
    if expires_delta:
        expire = datetime.now(timezone.utc) + expires_delta
    else:
        expire = datetime.now(timezone.utc) + timedelta(
            minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
        )
    
    to_encode.update({"exp": expire})
    
    # 生成令牌
    encoded_jwt = jwt.encode(
        to_encode, 
        settings.SECRET_KEY, 
        algorithm=settings.ALGORITHM
    )
    
    return encoded_jwt

def verify_token(token: str) -> Optional[TokenData]:
    """验证JWT令牌"""
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="无效的认证凭证",
        headers={"WWW-Authenticate": "Bearer"},
    )
    
    try:
        # 解码令牌
        payload = jwt.decode(
            token, 
            settings.SECRET_KEY, 
            algorithms=[settings.ALGORITHM]
        )
        
        # 获取用户信息
        username: str = payload.get("sub")
        user_id: int = payload.get("user_id")
        role: str = payload.get("role")
        
        if username is None:
            raise credentials_exception
        
        return TokenData(username=username, user_id=user_id, role=role)
        
    except JWTError:
        raise credentials_exception

def generate_password_reset_token(email: str) -> str:
    """生成密码重置令牌"""
    expires_delta = timedelta(hours=24)  # 24小时有效
    return create_access_token(
        data={"sub": email, "type": "reset_password"},
        expires_delta=expires_delta
    )

def verify_password_reset_token(token: str) -> Optional[str]:
    """验证密码重置令牌"""
    try:
        payload = jwt.decode(
            token,
            settings.SECRET_KEY,
            algorithms=[settings.ALGORITHM]
        )
        
        email: str = payload.get("sub")
        token_type: str = payload.get("type")
        
        if email is None or token_type != "reset_password":
            return None
        
        return email
        
    except JWTError:
        return None

# macOS特化:密钥安全检查
def check_security_keys():
    """检查安全密钥设置"""
    if settings.SECRET_KEY == "changeme_in_production":
        print("⚠️  WARNING: 请在生产环境中修改SECRET_KEY!")
    
    # 检查密钥长度
    if len(settings.SECRET_KEY) < 32:
        print("⚠️  WARNING: SECRET_KEY长度不足,建议至少32字符")

# 启动时检查
check_security_keys()
EOF

9. 创建认证依赖项

(venv) $ cat > backend/app/api/dependencies.py << 'EOF'
"""
macOS特化的FastAPI依赖项
用户认证和权限检查
"""
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.core.security import verify_token
from app.models.user import User
from app.schemas.user import TokenData

# HTTP Bearer认证
security = HTTPBearer(auto_error=False)

async def get_current_user(
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
    db: Session = Depends(get_db)
) -> User:
    """获取当前用户"""
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="无效的认证凭证",
        headers={"WWW-Authenticate": "Bearer"},
    )
    
    if credentials is None:
        raise credentials_exception
    
    # 验证令牌
    token_data = verify_token(credentials.credentials)
    if token_data is None:
        raise credentials_exception
    
    # 查询用户
    user = db.query(User).filter(User.username == token_data.username).first()
    if user is None:
        raise credentials_exception
    
    # 检查用户状态
    if not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="用户已被禁用"
        )
    
    return user

async def get_current_active_user(
    current_user: User = Depends(get_current_user)
) -> User:
    """获取当前活跃用户"""
    if not current_user.is_active:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="用户已被禁用"
        )
    return current_user

async def get_current_admin_user(
    current_user: User = Depends(get_current_user)
) -> User:
    """获取当前管理员用户"""
    if not current_user.is_admin:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="需要管理员权限"
        )
    return current_user

async def get_current_teacher_or_admin(
    current_user: User = Depends(get_current_user)
) -> User:
    """获取当前老师或管理员"""
    if not (current_user.is_teacher or current_user.is_admin):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="需要老师或管理员权限"
        )
    return current_user

async def optional_current_user(
    credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
    db: Session = Depends(get_db)
) -> Optional[User]:
    """可选的当前用户(未登录返回None)"""
    if credentials is None:
        return None
    
    try:
        token_data = verify_token(credentials.credentials)
        if token_data is None:
            return None
        
        user = db.query(User).filter(User.username == token_data.username).first()
        if user and user.is_active:
            return user
        return None
        
    except Exception:
        return None
EOF

10. 创建用户CRUD操作

(venv) $ cat > backend/app/crud/user.py << 'EOF'
"""
用户CRUD操作
"""
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import func

from app.models.user import User
from app.schemas.user import UserCreate, UserUpdate
from app.core.security import get_password_hash, verify_password

def get_user(db: Session, user_id: int) -> Optional[User]:
    """根据ID获取用户"""
    return db.query(User).filter(User.id == user_id).first()

def get_user_by_username(db: Session, username: str) -> Optional[User]:
    """根据用户名获取用户"""
    return db.query(User).filter(User.username == username).first()

def get_user_by_email(db: Session, email: str) -> Optional[User]:
    """根据邮箱获取用户"""
    return db.query(User).filter(User.email == email).first()

def get_users(
    db: Session,
    skip: int = 0,
    limit: int = 100,
    role: Optional[str] = None,
    is_active: Optional[bool] = None
) -> list[User]:
    """获取用户列表(带过滤)"""
    query = db.query(User)
    
    if role:
        query = query.filter(User.role == role)
    
    if is_active is not None:
        query = query.filter(User.is_active == is_active)
    
    return query.order_by(User.created_at.desc()).offset(skip).limit(limit).all()

def create_user(db: Session, user_create: UserCreate) -> User:
    """创建新用户"""
    # 检查用户名是否已存在
    existing_user = get_user_by_username(db, user_create.username)
    if existing_user:
        raise ValueError("用户名已存在")
    
    # 检查邮箱是否已存在(如果提供了邮箱)
    if user_create.email:
        existing_email = get_user_by_email(db, user_create.email)
        if existing_email:
            raise ValueError("邮箱已存在")
    
    # 创建用户对象
    db_user = User(
        username=user_create.username,
        email=user_create.email,
        hashed_password=get_password_hash(user_create.password),
        full_name=user_create.full_name,
        role=user_create.role,
        grade=user_create.grade,
        school=user_create.school,
        is_verified=False,  # 默认未验证
        is_active=True,     # 默认激活
    )
    
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    
    return db_user

def update_user(
    db: Session,
    db_user: User,
    user_update: UserUpdate
) -> User:
    """更新用户信息"""
    update_data = user_update.model_dump(exclude_unset=True)
    
    # 如果需要更新邮箱,检查是否重复
    if 'email' in update_data and update_data['email']:
        existing_user = get_user_by_email(db, update_data['email'])
        if existing_user and existing_user.id != db_user.id:
            raise ValueError("邮箱已被使用")
    
    # 更新字段
    for field, value in update_data.items():
        setattr(db_user, field, value)
    
    db.commit()
    db.refresh(db_user)
    
    return db_user

def delete_user(db: Session, user_id: int) -> bool:
    """删除用户(软删除)"""
    user = get_user(db, user_id)
    if not user:
        return False
    
    user.is_active = False
    db.commit()
    
    return True

def authenticate_user(
    db: Session,
    username: str,
    password: str
) -> Optional[User]:
    """用户认证"""
    user = get_user_by_username(db, username)
    if not user:
        return None
    
    if not verify_password(password, user.hashed_password):
        return None
    
    return user

def change_password(
    db: Session,
    user: User,
    current_password: str,
    new_password: str
) -> bool:
    """修改密码"""
    # 验证当前密码
    if not verify_password(current_password, user.hashed_password):
        return False
    
    # 更新密码
    user.hashed_password = get_password_hash(new_password)
    db.commit()
    
    return True

def get_user_stats(db: Session, user_id: int) -> Dict[str, Any]:
    """获取用户统计信息"""
    from app.models.practice import PracticeSession
    from app.models.answer import AnswerRecord
    
    # 练习会话统计
    session_stats = db.query(
        func.count(PracticeSession.id).label("total_sessions"),
        func.sum(PracticeSession.total_duration).label("total_duration"),
        func.avg(PracticeSession.total_duration).label("avg_duration"),
    ).filter(
        PracticeSession.user_id == user_id,
        PracticeSession.status == "completed"
    ).first()
    
    # 答题统计
    answer_stats = db.query(
        func.count(AnswerRecord.id).label("total_answers"),
        func.sum(func.cast(AnswerRecord.is_correct, Integer)).label("correct_answers"),
    ).filter(AnswerRecord.user_id == user_id).first()
    
    total_answers = answer_stats.total_answers or 0
    correct_answers = answer_stats.correct_answers or 0
    
    return {
        "total_practice_sessions": session_stats.total_sessions or 0,
        "total_questions_attempted": total_answers,
        "overall_accuracy": round(correct_answers / total_answers * 100, 2) if total_answers > 0 else 0,
        "total_practice_time": session_stats.total_duration or 0,
        "average_session_duration": round(session_stats.avg_duration or 0, 2),
    }

def search_users(
    db: Session,
    keyword: str,
    skip: int = 0,
    limit: int = 50
) -> list[User]:
    """搜索用户(根据用户名、姓名、学校)"""
    query = db.query(User).filter(
        User.is_active == True
    ).filter(
        (User.username.ilike(f"%{keyword}%")) |
        (User.full_name.ilike(f"%{keyword}%")) |
        (User.school.ilike(f"%{keyword}%"))
    )
    
    return query.order_by(User.username).offset(skip).limit(limit).all()

def count_users(db: Session) -> Dict[str, int]:
    """统计用户数量(按角色)"""
    result = db.query(
        User.role,
        func.count(User.id).label("count")
    ).filter(
        User.is_active == True
    ).group_by(
        User.role
    ).all()
    
    stats = {row.role: row.count for row in result}
    
    # 确保所有角色都有值
    for role in ["student", "teacher", "admin", "parent"]:
        if role not in stats:
            stats[role] = 0
    
    stats["total"] = sum(stats.values())
    
    return stats
EOF

下午 15:00-16:30 | 用户认证API端点

11. 创建用户认证API路由

(venv) $ cat > backend/app/api/routes/auth.py << 'EOF'
"""
用户认证API路由
注册、登录、令牌刷新等
"""
from datetime import timedelta
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status, Body
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

from app.core.config import settings
from app.core.database import get_db
from app.core.security import create_access_token, verify_token
from app.crud.user import (
    create_user, authenticate_user, get_user_by_username,
    change_password as crud_change_password
)
from app.schemas.user import (
    UserCreate, UserResponse, Token, UserLogin,
    ChangePassword
)
from app.api.dependencies import get_current_user, optional_current_user
from app.models.user import User

router = APIRouter()

@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
    user_in: UserCreate,
    db: Session = Depends(get_db),
    current_user: User = Depends(optional_current_user)
):
    """
    注册新用户
    
    - **username**: 用户名(3-50字符,只能包含字母数字和下划线)
    - **password**: 密码(至少6位)
    - **email**: 邮箱(可选)
    - **full_name**: 姓名(可选)
    - **role**: 角色(student, teacher, admin, parent),默认student
    """
    # 检查是否允许开放注册,或者当前用户是管理员
    if not settings.USERS_OPEN_REGISTRATION:
        if not current_user or not current_user.is_admin:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail="注册功能已关闭"
            )
    
    try:
        user = create_user(db, user_in)
        return user
    except ValueError as e:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=str(e)
        )

@router.post("/login", response_model=Token)
async def login(
    form_data: OAuth2PasswordRequestForm = Depends(),
    db: Session = Depends(get_db)
):
    """
    用户登录
    
    使用OAuth2标准格式:
    - **username**: 用户名
    - **password**: 密码
    """
    user = authenticate_user(db, form_data.username, form_data.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    if not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="用户已被禁用"
        )
    
    # 创建访问令牌
    access_token = create_access_token(
        data={
            "sub": user.username,
            "user_id": user.id,
            "role": user.role
        }
    )
    
    # 更新最后登录时间
    user.update_last_login()
    db.commit()
    
    return {
        "access_token": access_token,
        "token_type": "bearer",
        "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
    }

@router.post("/login/json", response_model=Token)
async def login_json(
    user_in: UserLogin,
    db: Session = Depends(get_db)
):
    """
    用户登录(JSON格式)
    
    - **username**: 用户名
    - **password**: 密码
    """
    user = authenticate_user(db, user_in.username, user_in.password)
    if not user:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户名或密码错误",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    if not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="用户已被禁用"
        )
    
    # 创建访问令牌
    access_token = create_access_token(
        data={
            "sub": user.username,
            "user_id": user.id,
            "role": user.role
        }
    )
    
    # 更新最后登录时间
    user.update_last_login()
    db.commit()
    
    return {
        "access_token": access_token,
        "token_type": "bearer",
        "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
    }

@router.post("/refresh", response_model=Token)
async def refresh_token(
    refresh_token: str = Body(..., embed=True),
    db: Session = Depends(get_db)
):
    """
    刷新访问令牌
    
    - **refresh_token**: 刷新令牌(当前使用访问令牌)
    """
    # 验证令牌
    token_data = verify_token(refresh_token)
    if not token_data:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="无效的令牌"
        )
    
    # 获取用户
    user = get_user_by_username(db, token_data.username)
    if not user or not user.is_active:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="用户不存在或已被禁用"
        )
    
    # 创建新的访问令牌
    access_token = create_access_token(
        data={
            "sub": user.username,
            "user_id": user.id,
            "role": user.role
        }
    )
    
    return {
        "access_token": access_token,
        "token_type": "bearer",
        "expires_in": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60
    }

@router.post("/change-password")
async def change_password(
    password_data: ChangePassword,
    current_user: User = Depends(get_current_user),
    db: Session = Depends(get_db)
):
    """
    修改密码
    
    - **current_password**: 当前密码
    - **new_password**: 新密码
    """
    success = crud_change_password(
        db,
        current_user,
        password_data.current_password,
        password_data.new_password
    )
    
    if not success:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="当前密码错误"
        )
    
    return {"message": "密码修改成功"}

@router.get("/me", response_model=UserResponse)
async def get_current_user_info(
    current_user: User = Depends(get_current_user)
):
    """
    获取当前用户信息
    """
    return current_user

@router.post("/logout")
async def logout():
    """
    用户登出(客户端需删除令牌)
    """
    # JWT是无状态的,客户端删除令牌即可
    return {"message": "登出成功"}

@router.get("/check-username/{username}")
async def check_username_available(
    username: str,
    db: Session = Depends(get_db)
):
    """
    检查用户名是否可用
    
    - **username**: 要检查的用户名
    """
    user = get_user_by_username(db, username)
    return {"available": user is None}

@router.get("/check-email/{email}")
async def check_email_available(
    email: str,
    db: Session = Depends(get_db)
):
    """
    检查邮箱是否可用
    
    - **email**: 要检查的邮箱
    """
    user = get_user_by_email(db, email)
    return {"available": user is None}

@router.get("/health")
async def health_check():
    """
    健康检查端点
    """
    import platform
    import psutil
    
    # 获取系统信息
    system_info = {
        "system": platform.system(),
        "machine": platform.machine(),
        "processor": platform.processor(),
        "python_version": platform.python_version(),
        "memory_usage": psutil.virtual_memory().percent,
        "cpu_usage": psutil.cpu_percent(interval=1),
    }
    
    return {
        "status": "healthy",
        "service": "auth",
        "timestamp": datetime.now().isoformat(),
        "system": system_info,
    }
EOF

下午 16:30-17:30 | 题目管理API端点

12. 创建题目相关的模式

(venv) $ cat > backend/app/schemas/problem.py << 'EOF'
"""
题目相关的Pydantic模式
"""
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, validator
from datetime import datetime

# 题目基础模式
class ProblemBase(BaseModel):
    """题目基础模式"""
    title: str = Field(..., min_length=1, max_length=200, description="题目标题")
    content: str = Field(..., description="题目内容")
    content_type: str = Field(default="text", description="内容类型: text, markdown, latex")
    options: Dict[str, str] = Field(..., description="题目选项,如{'A': '选项A', 'B': '选项B'}")
    correct_answer: str = Field(..., pattern='^[A-D]$', description="正确答案: A, B, C, D")
    solution: Optional[str] = None
    solution_type: str = Field(default="text", description="解析类型: text, markdown")
    difficulty: int = Field(default=3, ge=1, le=5, description="难度等级: 1-5")
    source_type: Optional[str] = None
    source_year: Optional[int] = None
    source_detail: Optional[str] = None
    estimated_time: Optional[int] = Field(None, ge=1, description="预估时间(秒)")
    
    @validator('options')
    def validate_options(cls, v):
        """验证选项格式"""
        required_keys = {'A', 'B', 'C', 'D'}
        if not required_keys.issubset(v.keys()):
            missing = required_keys - set(v.keys())
            raise ValueError(f"缺少选项: {', '.join(missing)}")
        return v

# 创建题目
class ProblemCreate(ProblemBase):
    """创建题目模式"""
    knowledge_point_ids: List[int] = Field(default=[], description="关联的知识点ID列表")
    is_published: bool = Field(default=False, description="是否立即发布")

# 更新题目
class ProblemUpdate(BaseModel):
    """更新题目模式"""
    title: Optional[str] = Field(None, min_length=1, max_length=200)
    content: Optional[str] = None
    content_type: Optional[str] = None
    options: Optional[Dict[str, str]] = None
    correct_answer: Optional[str] = Field(None, pattern='^[A-D]$')
    solution: Optional[str] = None
    solution_type: Optional[str] = None
    difficulty: Optional[int] = Field(None, ge=1, le=5)
    source_type: Optional[str] = None
    source_year: Optional[int] = None
    source_detail: Optional[str] = None
    estimated_time: Optional[int] = Field(None, ge=1)
    is_published: Optional[bool] = None
    knowledge_point_ids: Optional[List[int]] = None
    
    @validator('options')
    def validate_options(cls, v):
        if v is not None:
            required_keys = {'A', 'B', 'C', 'D'}
            if not required_keys.issubset(v.keys()):
                missing = required_keys - set(v.keys())
                raise ValueError(f"缺少选项: {', '.join(missing)}")
        return v

# 题目响应
class ProblemResponse(ProblemBase):
    """题目响应模式"""
    id: int
    is_published: bool
    review_status: str
    total_attempts: int
    correct_attempts: int
    accuracy_rate: float
    created_by: int
    created_at: datetime
    updated_at: Optional[datetime] = None
    
    class Config:
        from_attributes = True

# 题目详情
class ProblemDetail(ProblemResponse):
    """题目详情模式"""
    knowledge_points: Optional[List[Dict[str, Any]]] = None
    creator: Optional[Dict[str, Any]] = None

# 题目列表查询
class ProblemFilter(BaseModel):
    """题目过滤条件"""
    difficulty: Optional[List[int]] = None
    source_type: Optional[str] = None
    source_year: Optional[int] = None
    knowledge_point_id: Optional[int] = None
    is_published: Optional[bool] = True
    search: Optional[str] = None
    sort_by: str = "created_at"
    sort_order: str = "desc"

# 题目统计
class ProblemStats(BaseModel):
    """题目统计模式"""
    total_problems: int
    published_problems: int
    by_difficulty: Dict[int, int]
    by_source: Dict[str, int]
    avg_accuracy: float

# 练习题目(不包含答案)
class PracticeProblem(BaseModel):
    """练习用题目模式(隐藏答案)"""
    id: int
    title: str
    content: str
    content_type: str
    options: Dict[str, str]
    difficulty: int
    estimated_time: Optional[int] = None
    knowledge_points: Optional[List[Dict[str, Any]]] = None
EOF

13. 创建题目CRUD操作

(venv) $ cat > backend/app/crud/problem.py << 'EOF'
"""
题目CRUD操作
"""
from typing import Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import func, or_, and_

from app.models.problem import Problem, ProblemKnowledgePoint
from app.models.knowledge_point import KnowledgePoint
from app.schemas.problem import ProblemCreate, ProblemUpdate, ProblemFilter

def get_problem(db: Session, problem_id: int) -> Optional[Problem]:
    """根据ID获取题目"""
    return db.query(Problem).filter(Problem.id == problem_id).first()

def get_problems(
    db: Session,
    skip: int = 0,
    limit: int = 100,
    filter_params: Optional[ProblemFilter] = None
) -> List[Problem]:
    """获取题目列表(带过滤)"""
    query = db.query(Problem)
    
    if filter_params:
        # 难度过滤
        if filter_params.difficulty:
            query = query.filter(Problem.difficulty.in_(filter_params.difficulty))
        
        # 来源过滤
        if filter_params.source_type:
            query = query.filter(Problem.source_type == filter_params.source_type)
        
        if filter_params.source_year:
            query = query.filter(Problem.source_year == filter_params.source_year)
        
        # 知识点过滤
        if filter_params.knowledge_point_id:
            query = query.join(Problem.knowledge_points).filter(
                KnowledgePoint.id == filter_params.knowledge_point_id
            )
        
        # 发布状态过滤
        if filter_params.is_published is not None:
            query = query.filter(Problem.is_published == filter_params.is_published)
        
        # 搜索
        if filter_params.search:
            search_term = f"%{filter_params.search}%"
            query = query.filter(
                or_(
                    Problem.title.ilike(search_term),
                    Problem.content.ilike(search_term),
                    Problem.solution.ilike(search_term)
                )
            )
        
        # 排序
        sort_column = getattr(Problem, filter_params.sort_by, Problem.created_at)
        if filter_params.sort_order == "desc":
            query = query.order_by(sort_column.desc())
        else:
            query = query.order_by(sort_column.asc())
    
    # 排除已删除的题目
    query = query.filter(Problem.is_deleted == False)
    
    return query.offset(skip).limit(limit).all()

def create_problem(
    db: Session,
    problem_create: ProblemCreate,
    created_by: int
) -> Problem:
    """创建新题目"""
    # 创建题目对象
    db_problem = Problem(
        title=problem_create.title,
        content=problem_create.content,
        content_type=problem_create.content_type,
        options=problem_create.options,
        correct_answer=problem_create.correct_answer,
        solution=problem_create.solution,
        solution_type=problem_create.solution_type,
        difficulty=problem_create.difficulty,
        source_type=problem_create.source_type,
        source_year=problem_create.source_year,
        source_detail=problem_create.source_detail,
        estimated_time=problem_create.estimated_time,
        is_published=problem_create.is_published,
        created_by=created_by,
    )
    
    db.add(db_problem)
    db.commit()
    db.refresh(db_problem)
    
    # 关联知识点
    if problem_create.knowledge_point_ids:
        for kp_id in problem_create.knowledge_point_ids:
            # 验证知识点是否存在
            knowledge_point = db.query(KnowledgePoint).filter(
                KnowledgePoint.id == kp_id
            ).first()
            
            if knowledge_point:
                association = ProblemKnowledgePoint(
                    problem_id=db_problem.id,
                    knowledge_point_id=kp_id,
                    is_primary=True if kp_id == problem_create.knowledge_point_ids[0] else False
                )
                db.add(association)
        
        db.commit()
        db.refresh(db_problem)
    
    return db_problem

def update_problem(
    db: Session,
    db_problem: Problem,
    problem_update: ProblemUpdate
) -> Problem:
    """更新题目"""
    update_data = problem_update.model_dump(exclude_unset=True)
    
    # 处理知识点更新
    knowledge_point_ids = update_data.pop("knowledge_point_ids", None)
    
    # 更新其他字段
    for field, value in update_data.items():
        setattr(db_problem, field, value)
    
    # 更新知识点关联
    if knowledge_point_ids is not None:
        # 删除现有关联
        db.query(ProblemKnowledgePoint).filter(
            ProblemKnowledgePoint.problem_id == db_problem.id
        ).delete()
        
        # 添加新关联
        for kp_id in knowledge_point_ids:
            knowledge_point = db.query(KnowledgePoint).filter(
                KnowledgePoint.id == kp_id
            ).first()
            
            if knowledge_point:
                association = ProblemKnowledgePoint(
                    problem_id=db_problem.id,
                    knowledge_point_id=kp_id,
                    is_primary=True if kp_id == knowledge_point_ids[0] else False
                )
                db.add(association)
    
    db.commit()
    db.refresh(db_problem)
    
    return db_problem

def delete_problem(db: Session, problem_id: int) -> bool:
    """删除题目(软删除)"""
    problem = get_problem(db, problem_id)
    if not problem:
        return False
    
    problem.is_deleted = True
    db.commit()
    
    return True

def publish_problem(db: Session, problem_id: int, publish: bool = True) -> bool:
    """发布或取消发布题目"""
    problem = get_problem(db, problem_id)
    if not problem:
        return False
    
    problem.is_published = publish
    problem.review_status = "approved" if publish else "pending"
    
    if publish:
        problem.reviewed_at = func.now()
    
    db.commit()
    return True

def get_problem_stats(db: Session) -> Dict[str, Any]:
    """获取题目统计信息"""
    # 总数
    total = db.query(func.count(Problem.id)).filter(
        Problem.is_deleted == False
    ).scalar()
    
    # 已发布数量
    published = db.query(func.count(Problem.id)).filter(
        Problem.is_deleted == False,
        Problem.is_published == True
    ).scalar()
    
    # 按难度统计
    difficulty_stats = db.query(
        Problem.difficulty,
        func.count(Problem.id).label("count")
    ).filter(
        Problem.is_deleted == False,
        Problem.is_published == True
    ).group_by(
        Problem.difficulty
    ).all()
    
    difficulty_dict = {row.difficulty: row.count for row in difficulty_stats}
    
    # 按来源统计
    source_stats = db.query(
        Problem.source_type,
        func.count(Problem.id).label("count")
    ).filter(
        Problem.is_deleted == False,
        Problem.is_published == True,
        Problem.source_type.isnot(None)
    ).group_by(
        Problem.source_type
    ).all()
    
    source_dict = {row.source_type: row.count for row in source_stats}
    
    # 平均正确率
    avg_accuracy = db.query(
        func.avg(
            func.cast(Problem.correct_attempts, func.FLOAT) /
            func.nullif(func.cast(Problem.total_attempts, func.FLOAT), 0)
        ).label("avg_accuracy")
    ).filter(
        Problem.is_deleted == False,
        Problem.total_attempts > 0
    ).scalar() or 0.0
    
    return {
        "total_problems": total or 0,
        "published_problems": published or 0,
        "by_difficulty": difficulty_dict,
        "by_source": source_dict,
        "avg_accuracy": round(avg_accuracy * 100, 2),
    }

def get_random_problems(
    db: Session,
    count: int = 10,
    difficulty_range: Optional[List[int]] = None,
    knowledge_point_ids: Optional[List[int]] = None
) -> List[Problem]:
    """获取随机题目"""
    query = db.query(Problem).filter(
        Problem.is_deleted == False,
        Problem.is_published == True
    )
    
    # 难度过滤
    if difficulty_range:
        query = query.filter(Problem.difficulty.in_(difficulty_range))
    
    # 知识点过滤
    if knowledge_point_ids:
        query = query.join(Problem.knowledge_points).filter(
            KnowledgePoint.id.in_(knowledge_point_ids)
        )
    
    # 随机排序并限制数量
    query = query.order_by(func.random()).limit(count)
    
    return query.all()

def increment_attempts(db: Session, problem_id: int, is_correct: bool) -> bool:
    """增加题目答题统计"""
    problem = get_problem(db, problem_id)
    if not problem:
        return False
    
    problem.increment_attempts(is_correct)
    db.commit()
    
    return True

def search_problems(
    db: Session,
    keyword: str,
    skip: int = 0,
    limit: int = 50
) -> List[Problem]:
    """搜索题目"""
    search_term = f"%{keyword}%"
    
    query = db.query(Problem).filter(
        Problem.is_deleted == False,
        Problem.is_published == True
    ).filter(
        or_(
            Problem.title.ilike(search_term),
            Problem.content.ilike(search_term),
            Problem.solution.ilike(search_term)
        )
    )
    
    return query.order_by(Problem.created_at.desc()).offset(skip).limit(limit).all()
EOF

14. 创建题目API路由

(venv) $ cat > backend/app/api/routes/problems.py << 'EOF'
"""
题目管理API路由
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.api.dependencies import get_current_user, get_current_admin_user, get_current_teacher_or_admin
from app.crud.problem import (
    get_problem, get_problems, create_problem, update_problem,
    delete_problem, publish_problem, get_problem_stats,
    get_random_problems, search_problems
)
from app.schemas.problem import (
    ProblemCreate, ProblemUpdate, ProblemResponse,
    ProblemDetail, ProblemFilter, ProblemStats, PracticeProblem
)
from app.models.user import User

router = APIRouter()

@router.get("/", response_model=List[ProblemResponse])
async def read_problems(
    skip: int = 0,
    limit: int = 100,
    difficulty: Optional[List[int]] = Query(None),
    source_type: Optional[str] = None,
    knowledge_point_id: Optional[int] = None,
    search: Optional[str] = None,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    获取题目列表
    
    权限:需要登录
    - **skip**: 跳过多少条记录(分页)
    - **limit**: 返回多少条记录(分页)
    - **difficulty**: 难度过滤(可以多个)
    - **source_type**: 来源类型过滤
    - **knowledge_point_id**: 知识点ID过滤
    - **search**: 搜索关键词
    """
    # 构建过滤条件
    filter_params = ProblemFilter(
        difficulty=difficulty,
        source_type=source_type,
        knowledge_point_id=knowledge_point_id,
        search=search,
        is_published=True  # 普通用户只能看到已发布的题目
    )
    
    if current_user.is_admin or current_user.is_teacher:
        # 管理员和老师可以看到所有题目(包括未发布的)
        filter_params.is_published = None
    
    problems = get_problems(db, skip=skip, limit=limit, filter_params=filter_params)
    return problems

@router.get("/{problem_id}", response_model=ProblemDetail)
async def read_problem(
    problem_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    获取题目详情
    
    权限:需要登录
    - **problem_id**: 题目ID
    """
    problem = get_problem(db, problem_id)
    if not problem:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="题目不存在"
        )
    
    # 检查权限
    if not problem.is_published and not (current_user.is_admin or current_user.is_teacher):
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="没有权限查看此题目"
        )
    
    return problem

@router.post("/", response_model=ProblemResponse, status_code=status.HTTP_201_CREATED)
async def create_new_problem(
    problem_in: ProblemCreate,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_teacher_or_admin)
):
    """
    创建新题目
    
    权限:需要老师或管理员权限
    """
    try:
        problem = create_problem(db, problem_in, created_by=current_user.id)
        return problem
    except ValueError as e:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=str(e)
        )

@router.put("/{problem_id}", response_model=ProblemResponse)
async def update_existing_problem(
    problem_id: int,
    problem_in: ProblemUpdate,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_teacher_or_admin)
):
    """
    更新题目
    
    权限:需要老师或管理员权限
    - **problem_id**: 题目ID
    """
    problem = get_problem(db, problem_id)
    if not problem:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="题目不存在"
        )
    
    # 检查权限(只有创建者或管理员可以修改)
    if problem.created_by != current_user.id and not current_user.is_admin:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="没有权限修改此题目"
        )
    
    try:
        updated_problem = update_problem(db, problem, problem_in)
        return updated_problem
    except ValueError as e:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail=str(e)
        )

@router.delete("/{problem_id}")
async def delete_existing_problem(
    problem_id: int,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_admin_user)
):
    """
    删除题目
    
    权限:需要管理员权限
    - **problem_id**: 题目ID
    """
    success = delete_problem(db, problem_id)
    if not success:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="题目不存在"
        )
    
    return {"message": "题目删除成功"}

@router.post("/{problem_id}/publish")
async def publish_existing_problem(
    problem_id: int,
    publish: bool = True,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_teacher_or_admin)
):
    """
    发布或取消发布题目
    
    权限:需要老师或管理员权限
    - **problem_id**: 题目ID
    - **publish**: 是否发布(True=发布,False=取消发布)
    """
    success = publish_problem(db, problem_id, publish)
    if not success:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="题目不存在"
        )
    
    action = "发布" if publish else "取消发布"
    return {"message": f"题目{action}成功"}

@router.get("/stats/summary", response_model=ProblemStats)
async def get_problems_stats(
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    获取题目统计信息
    
    权限:需要登录
    """
    stats = get_problem_stats(db)
    return stats

@router.get("/practice/random", response_model=List[PracticeProblem])
async def get_random_problems_for_practice(
    count: int = Query(default=10, ge=1, le=50),
    difficulty: Optional[List[int]] = Query(None),
    knowledge_point_ids: Optional[List[int]] = Query(None),
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    获取随机题目用于练习
    
    权限:需要登录
    - **count**: 题目数量(1-50)
    - **difficulty**: 难度范围
    - **knowledge_point_ids**: 知识点ID列表
    """
    problems = get_random_problems(
        db,
        count=count,
        difficulty_range=difficulty,
        knowledge_point_ids=knowledge_point_ids
    )
    
    # 转换为练习模式(隐藏答案)
    practice_problems = []
    for problem in problems:
        practice_problems.append({
            "id": problem.id,
            "title": problem.title,
            "content": problem.content,
            "content_type": problem.content_type,
            "options": problem.options,
            "difficulty": problem.difficulty,
            "estimated_time": problem.estimated_time,
            "knowledge_points": [
                {"id": kp.id, "name": kp.name, "code": kp.code}
                for kp in problem.knowledge_points
            ] if problem.knowledge_points else []
        })
    
    return practice_problems

@router.get("/search/{keyword}", response_model=List[ProblemResponse])
async def search_problems_by_keyword(
    keyword: str,
    skip: int = 0,
    limit: int = 50,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    搜索题目
    
    权限:需要登录
    - **keyword**: 搜索关键词
    - **skip**: 分页跳过
    - **limit**: 分页限制
    """
    problems = search_problems(db, keyword, skip=skip, limit=limit)
    return problems

@router.post("/{problem_id}/attempt")
async def record_problem_attempt(
    problem_id: int,
    is_correct: bool,
    db: Session = Depends(get_db),
    current_user: User = Depends(get_current_user)
):
    """
    记录题目答题尝试
    
    权限:需要登录
    - **problem_id**: 题目ID
    - **is_correct**: 是否正确
    """
    success = increment_attempts(db, problem_id, is_correct)
    if not success:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="题目不存在"
        )
    
    return {"message": "答题记录已更新"}
EOF

傍晚 17:30-18:00 | 主应用和启动脚本

15. 创建主FastAPI应用

(venv) $ cat > backend/app/main.py << 'EOF'
"""
macOS特化的FastAPI主应用
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager
import time

from app.core.config import settings
from app.core.logging_config import logger
from app.core.database import init_db
from app.api.routes import auth, problems

# 应用生命周期管理
@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    应用生命周期管理
    启动时和关闭时的操作
    """
    # 启动时
    startup_time = time.time()
    logger.info(f"🚀 正在启动 {settings.PROJECT_NAME} v{settings.VERSION}")
    
    # 初始化数据库
    try:
        init_db()
        logger.info("✅ 数据库初始化完成")
    except Exception as e:
        logger.error(f"❌ 数据库初始化失败: {e}")
        raise
    
    # macOS特化:开发环境信息
    if settings.MACOS_DEV_MODE:
        import platform
        import psutil
        
        logger.info(f"💻 系统: {platform.system()} {platform.machine()}")
        logger.info(f"🐍 Python: {platform.python_version()}")
        logger.info(f"🧠 内存使用: {psutil.virtual_memory().percent}%")
        logger.info(f"⚡ CPU使用: {psutil.cpu_percent()}%")
    
    yield
    
    # 关闭时
    shutdown_time = time.time()
    uptime = shutdown_time - startup_time
    logger.info(f"🛑 应用关闭,运行时间: {uptime:.2f}秒")

# 创建FastAPI应用
app = FastAPI(
    title=settings.PROJECT_NAME,
    version=settings.VERSION,
    description="奥赛AI平台 - 智能数学奥赛学习系统",
    lifespan=lifespan,
    docs_url="/api/docs" if settings.MACOS_DEV_MODE else None,
    redoc_url="/api/redoc" if settings.MACOS_DEV_MODE else None,
    openapi_url="/api/openapi.json" if settings.MACOS_DEV_MODE else None,
)

# 配置CORS(跨域资源共享)
if settings.BACKEND_CORS_ORIGINS:
    app.add_middleware(
        CORSMiddleware,
        allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
        expose_headers=["*"],
    )

# 挂载静态文件(用于上传的文件)
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")

# 健康检查端点
@app.get("/")
async def root():
    """根端点"""
    return {
        "message": f"欢迎使用{settings.PROJECT_NAME} API",
        "version": settings.VERSION,
        "docs": "/api/docs" if settings.MACOS_DEV_MODE else "disabled",
        "status": "running"
    }

@app.get("/health")
async def health_check():
    """健康检查"""
    import psutil
    import platform
    
    return {
        "status": "healthy",
        "service": settings.PROJECT_NAME,
        "version": settings.VERSION,
        "timestamp": time.time(),
        "system": {
            "platform": platform.system(),
            "machine": platform.machine(),
            "python": platform.python_version(),
            "memory_usage": psutil.virtual_memory().percent,
        }
    }

# 注册API路由
app.include_router(
    auth.router,
    prefix="/api/v1/auth",
    tags=["认证"]
)

app.include_router(
    problems.router,
    prefix="/api/v1/problems",
    tags=["题目管理"]
)

# 全局异常处理器
@app.exception_handler(404)
async def not_found_exception_handler(request, exc):
    """404异常处理"""
    return JSONResponse(
        status_code=404,
        content={"message": "请求的资源不存在"},
    )

@app.exception_handler(500)
async def internal_exception_handler(request, exc):
    """500异常处理"""
    logger.error(f"服务器内部错误: {exc}", exc_info=True)
    return JSONResponse(
        status_code=500,
        content={"message": "服务器内部错误"},
    )

# macOS开发模式特化中间件
if settings.MACOS_DEV_MODE:
    from fastapi import Request
    from fastapi.responses import JSONResponse
    
    @app.middleware("http")
    async def log_requests(request: Request, call_next):
        """记录请求日志(开发环境)"""
        start_time = time.time()
        
        # 记录请求
        logger.info(f"🌐 {request.method} {request.url.path} - 开始")
        
        try:
            response = await call_next(request)
        except Exception as e:
            logger.error(f"❌ 请求处理失败: {e}", exc_info=True)
            raise
        
        # 计算处理时间
        process_time = time.time() - start_time
        response.headers["X-Process-Time"] = str(process_time)
        
        # 记录响应
        logger.info(
            f"✅ {request.method} {request.url.path} - "
            f"状态: {response.status_code} - "
            f"耗时: {process_time:.3f}秒"
        )
        
        return response

# 启动信息
logger.info(f"🎉 {settings.PROJECT_NAME} 应用创建完成")
logger.info(f"📚 API文档: http://localhost:8000/api/docs")
logger.info(f"🔗 健康检查: http://localhost:8000/health")
EOF

16. 创建macOS特化的启动脚本

(venv) $ cat > backend/start.sh << 'EOF'
#!/bin/bash
# macOS特化的FastAPI启动脚本

set -e

# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# 打印彩色信息
print_info() {
    echo -e "${BLUE}[INFO]${NC} $1"
}

print_success() {
    echo -e "${GREEN}[SUCCESS]${NC} $1"
}

print_warning() {
    echo -e "${YELLOW}[WARNING]${NC} $1"
}

print_error() {
    echo -e "${RED}[ERROR]${NC} $1"
}

# 检查虚拟环境
check_venv() {
    if [[ -z "$VIRTUAL_ENV" ]]; then
        print_error "未检测到虚拟环境,请先激活虚拟环境"
        echo "激活命令: source venv/bin/activate"
        exit 1
    fi
    print_info "虚拟环境: $(which python)"
}

# 检查依赖
check_dependencies() {
    print_info "检查Python依赖..."
    
    # 检查关键包
    for package in fastapi uvicorn sqlalchemy pydantic; do
        if ! python -c "import $package" 2>/dev/null; then
            print_error "缺少依赖: $package"
            echo "安装命令: pip install -r requirements.txt"
            exit 1
        fi
    done
    
    print_success "所有依赖检查通过"
}

# 检查数据库连接
check_database() {
    print_info "检查数据库连接..."
    
    # 尝试连接数据库
    if python -c "
import sys
sys.path.append('.')
from app.core.database import engine
from sqlalchemy import text
try:
    with engine.connect() as conn:
        conn.execute(text('SELECT 1'))
    print('✅ 数据库连接成功')
except Exception as e:
    print(f'❌ 数据库连接失败: {e}')
    sys.exit(1)
" 2>/dev/null; then
        print_success "数据库连接正常"
    else
        print_error "数据库连接失败,请检查PostgreSQL服务"
        echo "启动数据库: docker-compose up -d postgres"
        exit 1
    fi
}

# 清理缓存
clean_cache() {
    print_info "清理缓存文件..."
    
    # 清理Python缓存
    find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
    find . -name "*.pyc" -delete 2>/dev/null || true
    find . -name ".pytest_cache" -type d -exec rm -rf {} + 2>/dev/null || true
    
    # 清理日志文件(保留最新的)
    if [[ -d "logs" ]]; then
        find logs -name "*.log" -mtime +7 -delete 2>/dev/null || true
    fi
    
    print_success "缓存清理完成"
}

# 设置环境变量
setup_env() {
    print_info "设置环境变量..."
    
    # 确保.env文件存在
    if [[ ! -f ".env" ]]; then
        print_warning ".env文件不存在,使用默认配置"
        cat > .env << 'ENVFILE'
# macOS开发环境配置
DB_HOST=localhost
DB_PORT=5432
DB_NAME=olympiad
DB_USER=admin
DB_PASSWORD=olympiad123

APP_ENV=development
MACOS_DEV_MODE=true
HOT_RELOAD=true
ENVFILE
    fi
    
    # 加载环境变量
    export $(grep -v '^#' .env | xargs)
    print_success "环境变量设置完成"
}

# 启动应用
start_app() {
    print_info "正在启动FastAPI应用..."
    
    # 参数解析
    MODE="dev"
    PORT=8000
    HOST="0.0.0.0"
    
    while [[ $# -gt 0 ]]; do
        case $1 in
            --prod)
                MODE="prod"
                shift
                ;;
            --port)
                PORT="$2"
                shift 2
                ;;
            --host)
                HOST="$2"
                shift 2
                ;;
            *)
                print_warning "未知参数: $1"
                shift
                ;;
        esac
    done
    
    # 根据模式设置参数
    if [[ "$MODE" == "dev" ]]; then
        print_info "开发模式启动"
        RELOAD="--reload"
        LOG_LEVEL="debug"
    else
        print_info "生产模式启动"
        RELOAD=""
        LOG_LEVEL="info"
    fi
    
    # 显示启动信息
    echo ""
    echo "╔══════════════════════════════════════╗"
    echo "║      Math Olympiad AI Platform      ║"
    echo "║          FastAPI Backend            ║"
    echo "╚══════════════════════════════════════╝"
    echo ""
    echo "📊 模式: $MODE"
    echo "🌐 地址: http://$HOST:$PORT"
    echo "📚 文档: http://$HOST:$PORT/api/docs"
    echo "🔧 热重载: $([[ -n "$RELOAD" ]] && echo "启用" || echo "禁用")"
    echo ""
    
    # 启动命令
    uvicorn app.main:app \
        --host "$HOST" \
        --port "$PORT" \
        $RELOAD \
        --log-level "$LOG_LEVEL" \
        --access-log \
        --use-colors \
        --timeout-keep-alive 30
}

# 主函数
main() {
    echo "🚀 FastAPI macOS启动脚本"
    echo "════════════════════════════════════════"
    
    # 检查是否在backend目录
    if [[ ! -f "app/main.py" ]]; then
        cd "$(dirname "$0")"
        if [[ ! -f "app/main.py" ]]; then
            print_error "请在backend目录下运行此脚本"
            exit 1
        fi
    fi
    
    # 执行检查
    check_venv
    check_dependencies
    setup_env
    check_database
    clean_cache
    
    # 启动应用
    start_app "$@"
}

# 运行主函数
main "$@"
EOF

# 给脚本执行权限
(venv) $ chmod +x backend/start.sh

17. 创建数据库迁移配置(Alembic)

(venv) $ cat > backend/alembic.ini << 'EOF'
# A generic, single database configuration.

[alembic]
# path to migration scripts
script_location = alembic

# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s

# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .

# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =

# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40

# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false

# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false

# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses
# os.pathsep. If this key is omitted entirely, it falls back to the legacy
# behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os

# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false

# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8

[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts.  See the documentation for further
# detail and examples

# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME

# lint with attempts to fix using "ruff" - use the console_scripts runner, against the "ruff" entrypoint
# hooks = ruff
# ruff.type = console_scripts
# ruff.entrypoint = ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME

# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic

[handlers]
keys = console

[formatters]
keys = generic

[logger_root]
level = WARN
handlers = console
qualname =

[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine

[logger_alembic]
level = INFO
handlers =
qualname = alembic

[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic

[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
EOF

# 创建Alembic环境配置
(venv) $ cat > backend/alembic/env.py << 'EOF'
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context

import os
import sys

# 添加项目根目录到Python路径
sys.path.append(os.path.dirname(os.path.dirname(__file__)))

from app.core.config import settings
from app.core.database import Base
from app.models import user, problem, knowledge_point, practice

# Alembic配置对象
config = context.config

# 设置数据库URL
config.set_main_option("sqlalchemy.url", str(settings.SQLALCHEMY_DATABASE_URL))

# 配置日志
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# 目标元数据
target_metadata = Base.metadata

def run_migrations_offline() -> None:
    """离线运行迁移"""
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()

def run_migrations_online() -> None:
    """在线运行迁移"""
    connectable = engine_from_config(
        config.get_section(config.config_ini_section, {}),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection, target_metadata=target_metadata
        )

        with context.begin_transaction():
            context.run_migrations()

if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()
EOF

18. 测试启动后端服务

# 进入backend目录
(venv) $ cd backend

# 创建uploads目录
(venv) $ mkdir -p uploads

# 测试启动脚本
(venv) $ ./start.sh --port 8000

# 如果一切正常,你应该看到类似输出:
# 🚀 FastAPI macOS启动脚本
# ════════════════════════════════════════
# [INFO] 虚拟环境: /Users/yourname/Projects/math-olympiad/venv/bin/python
# [INFO] 检查Python依赖...
# [SUCCESS] 所有依赖检查通过
# [INFO] 设置环境变量...
# [SUCCESS] 环境变量设置完成
# [INFO] 检查数据库连接...
# ✅ 数据库连接成功
# [SUCCESS] 数据库连接正常
# [INFO] 清理缓存文件...
# [SUCCESS] 缓存清理完成

# ╔══════════════════════════════════════╗
# ║      Math Olympiad AI Platform      ║
# ║          FastAPI Backend            ║
# ╚══════════════════════════════════════╝

# 📊 模式: dev
# 🌐 地址: http://0.0.0.0:8000
# 📚 文档: http://0.0.0.0:8000/api/docs
# 🔧 热重载: 启用

# INFO:     Will watch for changes in these directories: ['/Users/.../backend']
# INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
# INFO:     Started reloader process [12345] using WatchFiles
# INFO:     Started server process [12346]
# INFO:     Waiting for application startup.
# INFO:     🚀 正在启动 Math Olympiad AI Platform v1.0.0
# INFO:     ✅ 数据库初始化完成
# INFO:     💻 系统: Darwin arm64
# ...

✅ 今日完成清单

  • 搭建完整的FastAPI项目结构(macOS特化)
  • 创建所有SQLAlchemy ORM模型(与Day 2的表对应)
  • 实现JWT用户认证系统(注册/登录/鉴权)
  • 创建题目CRUD API(完整增删改查)
  • 配置macOS特化的开发环境(热重载、彩色日志)
  • 创建macOS启动脚本和数据库迁移配置
  • 测试所有核心功能正常运行

🐛 macOS特有问题与解决方案

问题1:uvicorn热重载在macOS上不工作

现象:文件修改后服务不自动重启

解决方案

# 使用watchfiles替代watchgod(性能更好)
pip install watchfiles
# 在start.sh中使用--reload参数即可

问题2:端口被占用

现象:8000端口已被其他服务使用

解决方案

# 查看端口占用
$ lsof -i :8000
# 停止占用进程或换端口
$ ./start.sh --port 8001

问题3:虚拟环境激活问题

现象:每次新开终端都需要重新激活

解决方案:创建alias

# 添加到~/.zshrc
alias olympiad-backend="cd ~/Projects/math-olympiad && source venv/bin/activate && cd backend"

问题4:python版本各种库兼容问题,以及软连接不同的python库

💡 技术亮点

1. macOS特化配置

  • 彩色日志输出,便于开发调试
  • 系统资源监控(CPU/内存使用率)
  • 自动清理缓存和日志文件

2. 完整的安全实现

  • JWT令牌认证,支持刷新令牌
  • 密码哈希加密(bcrypt)
  • 角色权限控制(学生/老师/管理员)

3. 数据库设计一致性

  • ORM模型与Day 2的ER图完全对应
  • 支持多对多关系(题目-知识点)
  • 软删除机制,数据可恢复

4. 开发体验优化

  • 热重载支持,修改代码自动重启
  • 详细的API文档(自动生成)
  • 完整的错误处理和日志记录

🧠 学习收获

  1. FastAPI最佳实践

    • 依赖注入系统
    • Pydantic数据验证
    • 自动API文档生成
  2. SQLAlchemy 2.0特性

    • 声明式模型定义
    • 关系映射(relationship)
    • 查询优化技巧
  3. macOS开发技巧

    • 虚拟环境管理
    • 端口冲突解决
    • 系统资源监控

📈 (4)计划

  • 搭建Vue3前端项目结构
  • 实现用户登录/注册界面
  • 创建题库管理界面
  • 实现前后端联调
  • 配置macOS前端开发环境

🎯 明日重点关注

  1. Vue3组合式API:使用setup语法和Composition API
  2. Element Plus组件库:快速构建UI界面
  3. axios拦截器:处理API请求和响应
  4. macOS前端工具链:Vite、ESLint、Prettier配置

📝 今日金句

"好的后端API不仅要功能完整,更要让前端开发者用着舒服。"

🌟 心情小结

今天完成了后端的核心架构,看着API文档自动生成的那一刻,特别有成就感!FastAPI的自动文档功能真的太强大了。在macOS上开发,热重载和彩色日志让调试体验非常好。明天就可以开始前端开发,让整个项目真正"活"起来了!