智学领航 用户注册与登录系统详解

0 阅读11分钟

前言

在现代 Web 应用中,用户认证是必不可少的核心功能。本文将详细介绍智学领航项目中从前端页面后端 API再到数据库的完整用户注册与登录系统设计与实现。

系统架构总览

┌─────────────────────────────────────────────────────────────────┐
│                         前端 (HTML/CSS/JS)                       │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │  登录页面     │  │  注册页面    │  │  Token 存储与传递     │  │
│  │  login.html  │  │  (同一页面)  │   │  localStorage        │  │
│  └──────┬───────┘  └──────┬───────┘  └──────────┬───────────┘  │
└─────────┼──────────────────┼────────────────────┼──────────────┘
          │                  │                    │
          │    HTTP Request (JSON)                 │
          │                  │                    │
┌─────────┼──────────────────┼────────────────────┼──────────────┐
│         ▼                  ▼                    ▼              │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │                    FastAPI 后端服务                       │   │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────┐  │   │
│  │  │  Routes     │  │  Schemas    │  │  CRUD           │  │   │
│  │  │  routes/    │  │  schemas/   │  │  crud/          │  │   │
│  │  │  users.py   │→ │  users.py   │→ │  users.py       │  │   │
│  │  └─────────────┘  └─────────────┘  └────────┬────────┘  │   │
│  └──────────────────────────────────────────────┼───────────┘   │
│                                                  │               │
└──────────────────────────────────────────────────┼───────────────┘
                                                   │
                                          SQLAlchemy ORM
                                                   │
┌─────────────────────────────────────────────────┼───────────────┐
│                                                  ▼               │
│  ┌────────────────────────────────────────────────────────────────┐│
│  │                        MySQL 数据库                             ││
│  │  ┌──────────┐  ┌────────────┐  ┌─────────────────┐             ││
│  │  │   user   │  │ user_token │  │ student_profile │             ││
│  │  └──────────┘  └────────────┘  └─────────────────┘             ││
│  └────────────────────────────────────────────────────────────────┘│
└───────────────────────────────────────────────────────────────────┘

技术栈

层级技术说明
前端HTML5 + CSS3 + JavaScript原生 JS,无框架依赖
后端FastAPI高性能异步 Web 框架
ORMSQLAlchemy (Async)异步数据库操作
数据库MySQL关系型数据库
定时任务APScheduler定时任务调度
验证Pydantic数据验证与序列化

功能特性

功能说明
双账号支持支持用户名或手机号注册/登录
Token 认证UUID 随机 Token,有效期 7 天
自动会话创建注册/登录自动创建默认 AI 会话
密码管理支持密码修改
头像上传支持图片类型验证和安全上传
学生档案完善的学生信息管理
定时清理自动清理过期 Token

第一层:前端页面

1.1 页面结构 (login.html)

登录页面采用单页面应用设计,登录和注册表单共存,通过 JavaScript 切换显示。

核心 HTML 结构

<body>
    <div class="container">
        <!-- Logo 区域 -->
        <div class="logo-section">
            <div class="logo">🎓</div>
            <div class="title">智学领航</div>
        </div>

        <!-- 登录表单 -->
        <div id="loginForm">
            <!-- 登录方式切换:用户名 / 手机号 -->
            <div class="login-type-toggle">
                <button id="loginByUsernameBtn">用户名登录</button>
                <button id="loginByPhoneBtn">手机号登录</button>
            </div>

            <!-- 用户名输入框 -->
            <div class="form-group" id="loginUsernameGroup">
                <input type="text" id="loginUsername" placeholder="请输入用户名">
            </div>

            <!-- 手机号输入框 -->
            <div class="form-group hidden" id="loginPhoneGroup">
                <input type="text" id="loginPhone" placeholder="请输入手机号">
            </div>

            <!-- 密码输入框 -->
            <div class="form-group">
                <input type="password" id="loginPassword" placeholder="请输入密码">
            </div>

            <button onclick="handleLogin()">登录</button>
        </div>

        <!-- 注册表单(初始隐藏) -->
        <div id="registerForm" style="display: none;">
            <!-- 注册表单结构类似 -->
        </div>
    </div>

    <script src="login.js"></script>
</body>

1.2 登录方式切换

let loginType = 'username';

function setLoginType(type) {
    loginType = type;
    // 切换按钮激活状态
    document.getElementById('loginByUsernameBtn').classList.toggle('active', type === 'username');
    document.getElementById('loginByPhoneBtn').classList.toggle('active', type === 'phone');

    // 切换输入框显示
    document.getElementById('loginUsernameGroup').classList.toggle('hidden', type !== 'username');
    document.getElementById('loginPhoneGroup').classList.toggle('hidden', type !== 'phone');
}

1.3 登录请求处理

async function handleLogin() {
    const password = document.getElementById('loginPassword').value.trim();
    let loginData = { password };

    // 根据登录类型选择用户名或手机号
    if (loginType === 'username') {
        loginData.username = document.getElementById('loginUsername').value.trim();
    } else {
        loginData.phone = document.getElementById('loginPhone').value.trim();
    }

    // 表单验证
    if (!password) {
        showError('loginError', '请输入密码');
        return;
    }

    try {
        const response = await fetch('/api/user/login', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify(loginData)
        });

        const data = await response.json();

        if (response.ok && data.code === 200) {
            // 保存 Token 到 localStorage
            localStorage.setItem('token', data.data.token);
            // 跳转到首页
            window.location.href = '/';
        } else {
            showError('loginError', data.detail || '登录失败');
        }
    } catch (error) {
        showError('loginError', '网络错误,请稍后重试');
    }
}

1.4 Token 存储与使用

// 存储 Token
localStorage.setItem('token', data.data.token);

// 请求时携带 Token
fetch('/api/user/info', {
    headers: {
        'Authorization': `Bearer ${localStorage.getItem('token')}`
    }
});

// 清除 Token(退出登录)
localStorage.removeItem('token');

1.5 前端登录状态校验

前端通过 checkLoginStatus() 函数实现登录状态校验,在页面加载时自动调用。

// 页面加载时检查登录状态
async function checkLoginStatus() {
    const token = localStorage.getItem('token');

    if (token) {
        try {
            // 发送请求到后端验证 Token 有效性
            const response = await fetch('/api/user/info', {
                headers: { 'Authorization': `Bearer ${token}` }
            });

            if (response.ok) {
                const data = await response.json();
                // Token 有效,设置用户信息
                userNickname.textContent = data.data?.nickname || data.data?.username;
                userAvatar.src = data.data?.avatar || defaultAvatar;
                userInfo.classList.add('logged-in');
            } else {
                // Token 无效,清除并跳转登录
                localStorage.removeItem('token');
                userInfo.classList.remove('logged-in');
            }
        } catch (e) {
            localStorage.removeItem('token');
            userInfo.classList.remove('logged-in');
        }
    } else {
        userInfo.classList.remove('logged-in');
    }
}

image.png 校验流程

1. 页面加载 → checkLoginStatus()
2. 读取 localStorage 中的 token
3. 有 Token → 调用 /api/user/info 验证
4. 验证成功 → 显示用户信息
5. 验证失败 → 清除 Token,恢复未登录状态

第二层:数据验证层 (Schemas)

Pydantic Schemas 负责请求数据的验证、序列化和类型转换。

2.1 登录请求 Schema

class LoginRequest(BaseModel):
    """登录请求:支持用户名或手机号"""
    username: Optional[str] = Field(None, description="用户名")
    phone: Optional[str] = Field(None, description="手机号")
    password: str = Field(..., description="密码")

    @field_validator('username', 'phone', mode='before')
    @classmethod
    def check_at_least_one(cls, v):
        return v

    model_config = ConfigDict(populate_by_name=True)

2.2 注册请求 Schema

class RegisterRequest(BaseModel):
    """注册请求:支持用户名或手机号"""
    username: Optional[str] = Field(None, description="用户名")
    phone: Optional[str] = Field(None, description="手机号")
    password: str = Field(..., description="密码")
    nickname: Optional[str] = Field(None, max_length=50, description="昵称")

    @field_validator('username', 'phone', mode='before')
    @classmethod
    def check_at_least_one(cls, v):
        return v

2.3 用户信息响应 Schema

class UserInfoResponse(BaseModel):
    id: int
    username: Optional[str] = None
    phone: Optional[str] = None
    nickname: Optional[str] = None
    avatar: Optional[str] = None
    gender: Optional[str] = None
    bio: Optional[str] = None

    model_config = ConfigDict(from_attributes=True)


class UserAuthResponse(BaseModel):
    token: str
    user_info: UserInfoResponse = Field(..., alias="userInfo")

    model_config = ConfigDict(populate_by_name=True, from_attributes=True)

2.4 统一响应格式

def success_response(message: str = "success", data=None):
    content = {
        "code": 200,
        "message": message,
        "data": data
    }
    return JSONResponse(content=jsonable_encoder(content))

响应格式

{
    "code": 200,
    "message": "登录成功",
    "data": {
        "token": "550e8400-e29b-41d4-a716-446655440000",
        "userInfo": {
            "id": 1,
            "username": "demo",
            "nickname": "小明同学"
        }
    }
}

第三层:后端路由层 (Routes)

3.1 注册接口

@router.post("/register")
async def register(user_data: RegisterRequest, db: AsyncSession = Depends(get_db)):
    print(f'[DEBUG] 注册接口被调用')

    # 1. 验证用户唯一性
    if user_data.username:
        existing_user = await users.get_user_by_username(db, user_data.username)
        if existing_user:
            raise HTTPException(status_code=400, detail="用户名已存在")
    elif user_data.phone:
        existing_user = await users.get_user_by_phone(db, user_data.phone)
        if existing_user:
            raise HTTPException(status_code=400, detail="手机号已注册")
    else:
        raise HTTPException(status_code=400, detail="用户名和手机号至少需要提供一个")

    # 2. 创建用户
    user = await users.create_user(db, user_data)
    print(f'[DEBUG] 用户创建成功, user_id={user.id}')

    # 3. 自动创建默认 AI 会话
    session = await get_or_create_default_session(db, user.id)

    # 4. 生成 Token
    token = await users.create_token(db, user.id)

    # 5. 返回响应
    response_data = UserAuthResponse(
        token=token,
        user_info=UserInfoResponse.model_validate(user)
    )
    return success_response(message="注册成功", data=response_data)

3.2 登录接口

@router.post("/login")
async def login(user_data: LoginRequest, db: AsyncSession = Depends(get_db)):
    print(f'[DEBUG] 登录接口被调用')

    # 1. 验证用户身份
    user = await users.authenticate_user(db, user_data)
    if not user:
        raise HTTPException(status_code=401, detail="用户名/手机号或密码错误")

    print(f'[DEBUG] 用户验证成功, user_id={user.id}')

    # 2. 确保有默认会话
    session = await get_or_create_default_session(db, user.id)

    # 3. 生成 Token
    token = await users.create_token(db, user.id)

    # 4. 返回响应
    response_data = UserAuthResponse(
        token=token,
        user_info=UserInfoResponse.model_validate(user)
    )
    return success_response(message="登录成功啦", data=response_data)

3.3 用户认证中间件

async def get_current_user(
    authorization: str = Header(None, alias="Authorization"),
    db: AsyncSession = Depends(get_db)
):
    print(f"=== get_current_user 被调用 ===")

    if not authorization:
        raise HTTPException(status_code=401, detail="缺少 Authorization header")

    # 提取 Bearer Token
    token = authorization.replace("Bearer ", "")
    print(f"提取的token: {token[:20]}...")

    # 查询用户
    user = await users.get_user_by_token(db, token)
    if not user:
        raise HTTPException(status_code=401, detail="无效的令牌或已经过期的令牌")

    print(f"Token验证成功,用户: {user.username}")
    return user

3.4 认证拦截器机制(FastAPI 依赖注入)

项目采用 FastAPI 的 依赖注入(Dependency Injection) 机制实现认证拦截,而非传统中间件。

核心原理

# utils/auth.py
async def get_current_user(
    authorization: str = Header(None, alias="Authorization"),
    db: AsyncSession = Depends(get_db)
):
    """认证拦截器:通过 Depends 注入到需要认证的路由"""
    if not authorization:
        raise HTTPException(status_code=401, detail="缺少 Authorization header")

    token = authorization.replace("Bearer ", "")
    user = await users.get_user_by_token(db, token)
    if not user:
        raise HTTPException(status_code=401, detail="无效的令牌或已经过期的令牌")

    return user

使用方式:在路由函数参数中使用 Depends(get_current_user)

# routes/users.py
@router.get("/info")
async def get_user_info(user: User = Depends(get_current_user)):
    """需要认证的接口"""
    return success_response(message="获取用户信息成功", data=UserInfoResponse.model_validate(user))

拦截的接口(共 42 处)

路由文件拦截的接口
routes/users.py/info, /update, /password, /profile, /avatar/upload, /cleanup-tokens
routes/resume.py所有简历相关接口(10+ 个)
routes/chat.py所有 AI 会话相关接口(5+ 个)

认证流程

请求 → 中间件/路由处理函数
         ↓
    Depends(get_current_user)
         ↓
    1. 检查 Authorization Header 是否存在
    2. 提取 Bearer Token
    3. 查询数据库验证 Token
    4. 检查 Token 是否过期
    5. 返回 User 对象给路由处理函数
         ↓
    路由处理函数(已携带 User 对象)
         ↓
    业务逻辑处理

与中间件的区别

特性依赖注入中间件
控制粒度精确到单个路由全局所有路由
代码侵入低(只需添加 Depends)高(影响所有请求)
灵活性高(可选择性地添加)低(难以排除特定路由)
适用场景需要认证的 API全局日志、CORS 等

第四层:数据访问层 (CRUD)

4.1 用户创建

async def create_user(db: AsyncSession, user_data: RegisterRequest):
    if user_data.username:
        user = User(
            username=user_data.username,
            password=user_data.password,
            nickname=user_data.nickname
        )
    elif user_data.phone:
        user = User(
            phone=user_data.phone,
            password=user_data.password,
            nickname=user_data.nickname or f"用户{user_data.phone[-4:]}"
        )
    else:
        raise HTTPException(status_code=400, detail="用户名和手机号至少需要提供一个")

    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user

4.2 用户认证

async def authenticate_user(db: AsyncSession, login_data: LoginRequest):
    # 根据用户名或手机号查找用户
    if login_data.username:
        user = await get_user_by_username(db, login_data.username)
    elif login_data.phone:
        user = await get_user_by_phone(db, login_data.phone)
    else:
        return None

    if not user:
        return None

    # 验证密码(当前为明文比较,建议加密)
    if login_data.password != user.password:
        return None

    return user

4.3 Token 生成与管理

async def create_token(db: AsyncSession, user_id: int):
    # 生成 UUID Token
    token = str(uuid.uuid4())
    expires_at = datetime.now() + timedelta(days=7)

    # 查询是否已有 Token
    query = select(UserToken).where(UserToken.user_id == user_id)
    result = await db.execute(query)
    user_token = result.scalar_one_or_none()

    if user_token:
        # 更新现有 Token
        user_token.token = token
        user_token.expires_at = expires_at
    else:
        # 创建新 Token
        user_token = UserToken(
            user_id=user_id,
            token=token,
            expires_at=expires_at
        )
        db.add(user_token)

    await db.commit()
    return token


async def get_user_by_token(db: AsyncSession, token: str):
    query = select(UserToken).where(UserToken.token == token)
    result = await db.execute(query)
    db_token = result.scalar_one_or_none()

    # 检查 Token 是否过期
    if not db_token or db_token.expires_at < datetime.now():
        return None

    # 查询关联用户
    query = select(User).where(User.id == db_token.user_id)
    result = await db.execute(query)
    return result.scalar_one_or_none()

4.4 Token 过期清理

async def cleanup_expired_tokens(db: AsyncSession) -> int:
    """清理过期的 Token"""
    result = await db.execute(
        delete(UserToken).where(UserToken.expires_at < datetime.now())
    )
    await db.commit()
    return result.rowcount

第五层:数据库层 (Models)

5.1 数据库配置

# config/db_conf.py

ASYNC_DATABASE_URL = "mysql+aiomysql://root:3333@localhost:3306/fastapi-study-my?charset=utf8mb4"

async_engine = create_async_engine(
    ASYNC_DATABASE_URL,
    echo=True,  # 输出 SQL 日志
    pool_size=10,  # 持久连接数
    max_overflow=20  # 额外连接数
)

AsyncSessionLocal = async_sessionmaker(
    bind=async_engine,
    class_=AsyncSession,
    expire_on_commit=False
)


async def get_db():
    async with AsyncSessionLocal() as session:
        try:
            yield session
            await session.commit()
        except Exception:
            await session.rollback()
            raise
        finally:
            await session.close()

5.2 User 用户信息表

class User(Base):
    __tablename__ = 'user'

    __table_args__ = (
        Index('phone_UNIQUE', 'phone'),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    username: Mapped[Optional[str]] = mapped_column(String(50), unique=True, nullable=True)
    password: Mapped[str] = mapped_column(String(255), nullable=False)
    nickname: Mapped[Optional[str]] = mapped_column(String(50))
    avatar: Mapped[Optional[str]] = mapped_column(String(255))
    gender: Mapped[Optional[str]] = mapped_column(Enum('male', 'female', 'unknown'))
    bio: Mapped[Optional[str]] = mapped_column(String(500))
    phone: Mapped[Optional[str]] = mapped_column(String(20), unique=True, nullable=True)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now(), onupdate=datetime.now())

5.3 UserToken 用户令牌表

class UserToken(Base):
    __tablename__ = 'user_token'

    __table_args__ = (
        Index('token_UNIQUE', 'token'),
        Index('fk_user_token_user_idx', 'user_id'),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey(User.id), nullable=False)
    token: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
    expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now())

5.4 StudentProfile 学生档案表

class StudentProfile(Base):
    __tablename__ = 'student_profile'

    __table_args__ = (
        Index('idx_student_profile_user_id', 'user_id'),
    )

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    user_id: Mapped[int] = mapped_column(Integer, ForeignKey(User.id), nullable=False)
    name: Mapped[Optional[str]] = mapped_column(String(32))
    gender: Mapped[Optional[int]] = mapped_column(Integer)
    age: Mapped[Optional[int]] = mapped_column(Integer)
    school: Mapped[Optional[str]] = mapped_column(String(64))
    college: Mapped[Optional[str]] = mapped_column(String(64))
    major: Mapped[Optional[str]] = mapped_column(String(100))
    grade: Mapped[Optional[str]] = mapped_column(String(20))
    degree: Mapped[Optional[int]] = mapped_column(Integer)
    gpa: Mapped[Optional[str]] = mapped_column(String(10))
    has_internship: Mapped[Optional[int]] = mapped_column(Integer)
    interests: Mapped[Optional[str]] = mapped_column(String(500))
    career_goals: Mapped[Optional[str]] = mapped_column(String(500))
    skills: Mapped[Optional[str]] = mapped_column(String(500))
    phone: Mapped[Optional[str]] = mapped_column(String(16))
    email: Mapped[Optional[str]] = mapped_column(String(64))
    job_intention: Mapped[Optional[str]] = mapped_column(String(64))
    created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now())
    updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now(), onupdate=datetime.now())

数据流完整示例

注册流程

1. 前端:用户填写注册表单
   ↓ POST /api/user/register
2. Schema:验证 username/phone/password
   ↓
3. Route:检查用户唯一性
   ↓
4. CRUD:创建用户记录
   ↓
5. CRUD:创建 Token 记录
   ↓
6. CRUD:创建默认 AI 会话
   ↓
7. 返回:{ token, userInfo }
   ↓
8. 前端:localStorage.setItem('token', ...)

登录流程

1. 前端:用户填写登录表单
   ↓ POST /api/user/login
2. Schema:验证 username/phone/password
   ↓
3. CRUD:查找用户并验证密码
   ↓
4. CRUD:检查/创建默认 AI 会话
   ↓
5. CRUD:创建/更新 Token 记录
   ↓
6. 返回:{ token, userInfo }
   ↓
7. 前端:localStorage.setItem('token', ...)

认证流程

1. 前端:请求需要认证的接口
   ↓ Header: Authorization: Bearer <token>
2. Middleware:get_current_user 拦截
   ↓
3. CRUD:根据 Token 查询 UserToken
   ↓
4. 检查 Token 是否过期
   ↓
5. 查询关联的 User
   ↓
6. 返回 User 对象给路由处理函数

API 接口清单

接口方法说明
/api/user/registerPOST用户注册
/api/user/loginPOST用户登录
/api/user/infoGET获取用户信息(需认证)
/api/user/updatePUT更新用户信息(需认证)
/api/user/passwordPUT修改密码(需认证)
/api/user/profileGET/POST/PUT学生档案管理(需认证)
/api/user/avatar/uploadPOST上传头像(需认证)
/api/user/cleanup-tokensPOST清理过期 Token(需认证)

Token 过期清理机制

1. 手动清理接口

curl -X POST http://localhost:8000/api/user/cleanup-tokens \
  -H "Authorization: Bearer <token>"

2. 定时自动清理

项目集成 APScheduler 定时任务,每天凌晨 3:00 自动执行:

from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger

scheduler = AsyncIOScheduler()


async def cleanup_expired_tokens_job():
    async with AsyncSessionLocal() as session:
        try:
            deleted_count = await cleanup_expired_tokens(session)
            if deleted_count > 0:
                print(f"🧹 定时任务:已清理 {deleted_count} 条过期 Token")
        except Exception as e:
            print(f"❌ 清理过期 Token 失败: {e}")


@asynccontextmanager
async def lifespan(app: FastAPI):
    scheduler.add_job(
        cleanup_expired_tokens_job,
        CronTrigger(hour=3, minute=0),
        id="cleanup_expired_tokens",
        replace_existing=True
    )
    scheduler.start()
    yield
    scheduler.shutdown()

安全建议

当前项目已实现:

  • ✅ Token 过期自动清理(定时任务 + 手动接口)
  • ✅ Token 有效期控制(7 天)

仍需优化:

  1. 密码加密:建议使用 passlib 加密存储
  2. Token 刷新机制:添加 Token 刷新机制
  3. 手机号验证:添加短信验证码
  4. 速率限制:防止暴力破解
  5. HTTPS:生产环境必须使用

总结

本博客详细介绍了智学领航项目中用户注册与登录系统的完整实现,涵盖:

  • 前端层:登录/注册表单、Token 存储、API 请求
  • 验证层:Pydantic Schemas 数据验证
  • 路由层:FastAPI 路由处理、业务逻辑编排
  • 数据层:SQLAlchemy CRUD 操作
  • 数据库层:MySQL 数据表设计与索引

整个系统采用分层架构,各层职责清晰,便于维护和扩展。