前言
在现代 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 框架 |
| ORM | SQLAlchemy (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');
}
}
校验流程:
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/register | POST | 用户注册 |
/api/user/login | POST | 用户登录 |
/api/user/info | GET | 获取用户信息(需认证) |
/api/user/update | PUT | 更新用户信息(需认证) |
/api/user/password | PUT | 修改密码(需认证) |
/api/user/profile | GET/POST/PUT | 学生档案管理(需认证) |
/api/user/avatar/upload | POST | 上传头像(需认证) |
/api/user/cleanup-tokens | POST | 清理过期 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 天)
仍需优化:
- 密码加密:建议使用 passlib 加密存储
- Token 刷新机制:添加 Token 刷新机制
- 手机号验证:添加短信验证码
- 速率限制:防止暴力破解
- HTTPS:生产环境必须使用
总结
本博客详细介绍了智学领航项目中用户注册与登录系统的完整实现,涵盖:
- 前端层:登录/注册表单、Token 存储、API 请求
- 验证层:Pydantic Schemas 数据验证
- 路由层:FastAPI 路由处理、业务逻辑编排
- 数据层:SQLAlchemy CRUD 操作
- 数据库层:MySQL 数据表设计与索引
整个系统采用分层架构,各层职责清晰,便于维护和扩展。