Flask智能租房------用户中心知识点详解
一、用户注册
1.1 知识点概述
用户注册是系统中最基础的功能之一,涉及表单数据接收与验证 、密码加密存储 、数据库写入 、前后端数据交互(JSON响应) 等核心知识点。
1.2 核心知识点
| 知识点 |
说明 |
request.get_json() |
获取前端发送的JSON格式请求体数据 |
werkzeug.security |
Flask推荐的密码加密工具(generate_password_hash/check_password_hash) |
| 正则表达式验证 |
用re模块对手机号、邮箱等进行格式校验 |
| 数据库事务 |
使用db.session.add()和db.session.commit()完成数据持久化 |
| JSON响应 |
使用jsonify()返回标准JSON格式的响应数据 |
| 唯一性检查 |
注册前先查询用户名/手机号是否已存在 |
1.3 案例代码
(1)数据库模型定义
# models.py ------ 用户模型定义
from datetime import datetime # 导入datetime模块,用于记录时间戳
from werkzeug.security import generate_password_hash, \
check_password_hash # 导入密码哈希生成和校验函数
from flask_sqlalchemy import SQLAlchemy # 导入SQLAlchemy ORM
db = SQLAlchemy() # 创建SQLAlchemy实例,后续在app工厂中初始化
class User(db.Model):
"""用户模型类,映射到数据库中的 user 表"""
__tablename__ = 'user' # 指定数据库表名为 user
id = db.Column( # 主键字段
db.Integer, # 整数类型
primary_key=True, # 设为主键
autoincrement=True, # 自动递增
comment='用户ID' # 字段注释
)
username = db.Column( # 用户名字段
db.String(64), # 最长64个字符的字符串
unique=True, # 唯一约束,不允许重复
nullable=False, # 非空约束
comment='用户名'
)
password_hash = db.Column( # 密码哈希字段(注意:不存储明文密码)
db.String(256), # 哈希后的字符串较长,分配256字符
nullable=False, # 非空
comment='加密后的密码'
)
phone = db.Column( # 手机号字段
db.String(11), # 手机号固定11位
unique=True, # 唯一约束
nullable=True, # 允许为空(可选填写)
comment='手机号'
)
email = db.Column( # 邮箱字段
db.String(128), # 邮箱最长128字符
unique=True, # 唯一约束
nullable=True, # 允许为空
comment='邮箱地址'
)
avatar = db.Column( # 头像字段
db.String(256), # 存储头像文件的URL路径
nullable=True, # 可为空,使用默认头像
default='/static/images/default_avatar.png', # 默认头像路径
comment='用户头像URL'
)
create_time = db.Column( # 注册时间字段
db.DateTime, # 日期时间类型
default=datetime.now, # 默认值为当前时间(注意:不加括号,传入函数引用)
comment='注册时间'
)
# ===================== 密码相关属性和方法 =====================
@property # 将方法伪装成属性,这样可以用 user.password 访问
def password(self):
"""密码属性的getter,出于安全考虑禁止直接读取密码"""
raise AttributeError('密码不可读取') # 试图读取时抛出异常
@password.setter # 设置密码时自动进行哈希加密
def password(self, raw_password):
"""
密码属性的setter
:param raw_password: 用户输入的明文密码
作用:将明文密码转换为哈希值后存储到 password_hash 字段
"""
self.password_hash = generate_password_hash( # 调用werkzeug的哈希函数
raw_password, # 传入明文密码
method='pbkdf2:sha256', # 指定加密算法为pbkdf2+sha256
salt_length=8 # 盐值长度为8字节
)
def verify_password(self, raw_password):
"""
验证密码是否正确
:param raw_password: 用户输入的明文密码
:return: True-密码正确,False-密码错误
"""
return check_password_hash( # 调用哈希校验函数
self.password_hash, # 传入数据库中存储的哈希值
raw_password # 传入用户输入的明文密码
)
def to_dict(self):
"""
将用户对象序列化为字典(用于JSON响应)
注意:绝不能将密码哈希暴露给前端
"""
return {
'id': self.id, # 用户ID
'username': self.username, # 用户名
'phone': self.phone, # 手机号
'email': self.email, # 邮箱
'avatar': self.avatar, # 头像URL
'create_time': self.create_time.strftime( # 将datetime对象格式化为字符串
'%Y-%m-%d %H:%M:%S' # 指定日期时间格式
)
}
(2)注册接口后端实现
# views/user.py ------ 用户注册视图函数
import re # 导入正则表达式模块,用于数据格式校验
from flask import Blueprint, request, jsonify # 导入Flask核心组件
from models import db, User # 导入数据库实例和用户模型
from werkzeug.security import generate_password_hash # 导入密码哈希函数
# 创建用户相关的蓝图,统一前缀 /api/user
user_bp = Blueprint(
'user', # 蓝图名称
__name__, # 模块名
url_prefix='/api/user' # URL前缀,所有该蓝图的路由都以 /api/user 开头
)
@user_bp.route('/register', methods=['POST']) # 注册接口,仅接受POST请求
def register():
"""
用户注册接口
请求方式:POST
请求体格式:JSON
{
"username": "zhangsan", # 用户名(必填)
"password": "123456", # 密码(必填)
"password2": "123456", # 确认密码(必填)
"phone": "13800138000", # 手机号(可选)
"email": "zs@example.com" # 邮箱(可选)
}
"""
# ===================== 第一步:获取请求数据 =====================
data = request.get_json() # 获取前端发送的JSON数据,返回字典类型
if not data: # 如果请求体为空或不是JSON格式
return jsonify({ # 返回错误响应
'code': 400, # 状态码:400表示请求参数错误
'msg': '请求数据不能为空' # 错误提示信息
})
# ===================== 第二步:提取并校验必填字段 =====================
username = data.get('username', '').strip() # 获取用户名,默认空字符串,并去除首尾空格
password = data.get('password', '').strip() # 获取密码
password2 = data.get('password2', '').strip() # 获取确认密码
phone = data.get('phone', '').strip() # 获取手机号
email = data.get('email', '').strip() # 获取邮箱
# --- 校验用户名 ---
if not username: # 用户名为空
return jsonify({'code': 400, 'msg': '用户名不能为空'})
if len(username) < 3 or len(username) > 20: # 用户名长度限制
return jsonify({'code': 400, 'msg': '用户名长度应在3-20个字符之间'})
# --- 校验密码 ---
if not password: # 密码为空
return jsonify({'code': 400, 'msg': '密码不能为空'})
if len(password) < 6 or len(password) > 20: # 密码长度限制
return jsonify({'code': 400, 'msg': '密码长度应在6-20个字符之间'})
if password != password2: # 两次密码不一致
return jsonify({'code': 400, 'msg': '两次输入的密码不一致'})
# --- 校验手机号(如果填写了) ---
if phone:
phone_pattern = r'^1[3-9]\d{9}$' # 中国大陆手机号正则:1开头,第二位3-9,后面9位数字
if not re.match(phone_pattern, phone): # 正则匹配失败
return jsonify({'code': 400, 'msg': '手机号格式不正确'})
# --- 校验邮箱(如果填写了) ---
if email:
email_pattern = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' # 邮箱正则
if not re.match(email_pattern, email): # 正则匹配失败
return jsonify({'code': 400, 'msg': '邮箱格式不正确'})
# ===================== 第三步:检查用户名/手机号/邮箱是否已存在 =====================
# 使用 db.or_() 构建 OR 查询,一次查询判断多个字段
existing_user = User.query.filter(
db.or_( # SQLAlchemy的OR条件构造
User.username == username, # 条件1:用户名相同
User.phone == phone if phone else False, # 条件2:手机号相同(仅在phone非空时生效)
User.email == email if email else False # 条件3:邮箱相同(仅在email非空时生效)
)
).first() # 获取第一条匹配记录,没有则返回None
if existing_user: # 如果找到已存在的用户
if existing_user.username == username: # 判断是哪个字段重复
return jsonify({'code': 400, 'msg': '用户名已存在'})
if phone and existing_user.phone == phone:
return jsonify({'code': 400, 'msg': '手机号已被注册'})
if email and existing_user.email == email:
return jsonify({'code': 400, 'msg': '邮箱已被注册'})
# ===================== 第四步:创建用户并写入数据库 =====================
try:
new_user = User( # 创建User模型实例
username=username, # 设置用户名
)
new_user.password = password # 通过setter方法设置密码(自动哈希加密)
new_user.phone = phone if phone else None # 手机号,空字符串存储为None
new_user.email = email if email else None # 邮箱,空字符串存储为None
db.session.add(new_user) # 将新用户对象添加到数据库会话中(暂不提交)
db.session.commit() # 提交会话,真正执行SQL INSERT语句
return jsonify({ # 返回成功响应
'code': 200, # 状态码200表示成功
'msg': '注册成功', # 提示信息
'data': new_user.to_dict() # 返回新用户的序列化数据
})
except Exception as e: # 捕获数据库操作可能抛出的异常
db.session.rollback() # 回滚事务,撤销未提交的操作,防止数据不一致
return jsonify({
'code': 500, # 500表示服务器内部错误
'msg': f'注册失败:{str(e)}' # 返回具体错误信息(生产环境应隐藏细节)
})
(3)前端注册页面实现
<!-- register.html ------ 用户注册页面 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8"> <!-- 设置字符编码 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- 响应式视口 -->
<title>用户注册 - 智能租房</title>
<!-- 引入Vue.js框架,用于前端数据绑定和交互逻辑 -->
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script>
<!-- 引入axios库,用于发送HTTP请求 -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<!-- Vue应用挂载容器 -->
<div id="app">
<h2>用户注册</h2>
<!-- 注册表单 -->
<!-- @submit.prevent 阻止表单默认提交行为,改为调用handleRegister方法 -->
<form @submit.prevent="handleRegister">
<!-- 用户名输入框 -->
<!-- v-model 双向绑定,输入框内容变化时自动更新data中的username -->
<div>
<label>用户名:</label>
<input
type="text"
v-model="username" <!-- 双向绑定用户名 -->
placeholder="请输入3-20位用户名"
required <!-- HTML5原生必填验证 -->
>
</div>
<!-- 密码输入框 -->
<div>
<label>密码:</label>
<input
type="password" <!-- 密码类型,输入内容显示为圆点 -->
v-model="password" <!-- 双向绑定密码 -->
placeholder="请输入6-20位密码"
required
>
</div>
<!-- 确认密码输入框 -->
<div>
<label>确认密码:</label>
<input
type="password"
v-model="password2" <!-- 双向绑定确认密码 -->
placeholder="请再次输入密码"
required
>
</div>
<!-- 手机号输入框(可选) -->
<div>
<label>手机号:</label>
<input
type="text"
v-model="phone" <!-- 双向绑定手机号 -->
placeholder="请输入手机号(可选)"
>
</div>
<!-- 邮箱输入框(可选) -->
<div>
<label>邮箱:</label>
<input
type="email" <!-- email类型,浏览器会做基础格式校验 -->
v-model="email" <!-- 双向绑定邮箱 -->
placeholder="请输入邮箱(可选)"
>
</div>
<!-- 提交按钮 -->
<!-- :disabled="loading" 当loading为true时按钮禁用,防止重复提交 -->
<button type="submit" :disabled="loading">
{{ loading ? '注册中...' : '立即注册' }} <!-- 根据loading状态显示不同文本 -->
</button>
<!-- 错误/成功提示信息 -->
<!-- v-if 条件渲染,仅当message非空时显示 -->
<p v-if="message" :style="{ color: messageType === 'error' ? 'red' : 'green' }">
{{ message }} <!-- 显示提示消息 -->
</p>
</form>
</div>
<script>
// 创建Vue实例
new Vue({
el: '#app', // 挂载到id为app的DOM元素
// ===================== 数据定义 =====================
data: {
username: '', // 用户名,双向绑定到输入框
password: '', // 密码
password2: '', // 确认密码
phone: '', // 手机号
email: '', // 邮箱
loading: false, // 加载状态标志,防止重复提交
message: '', // 提示消息内容
messageType: '' // 提示消息类型:'error' 或 'success'
},
// ===================== 方法定义 =====================
methods: {
/**
* 处理注册表单提交
* 包含前端校验 + 发送POST请求到后端
*/
async handleRegister() {
// --- 前端基本校验 ---
if (!this.username || this.username.length < 3) {
this.message = '用户名至少3个字符'; // 设置错误消息
this.messageType = 'error'; // 设置消息类型为错误
return; // 终止函数执行
}
if (!this.password || this.password.length < 6) {
this.message = '密码至少6个字符';
this.messageType = 'error';
return;
}
if (this.password !== this.password2) {
this.message = '两次密码输入不一致';
this.messageType = 'error';
return;
}
// --- 发送注册请求 ---
this.loading = true; // 开始加载,禁用按钮
this.message = ''; // 清空之前的提示消息
try {
// 使用axios发送POST请求到注册接口
const response = await axios.post(
'/api/user/register', // 请求URL(后端注册接口地址)
{ // 请求体(JSON格式)
username: this.username,
password: this.password,
password2: this.password2,
phone: this.phone,
email: this.email
},
{ // 请求配置
headers: {
'Content-Type': 'application/json' // 指定请求体格式为JSON
}
}
);
// 根据后端返回的状态码判断是否注册成功
if (response.data.code === 200) { // code为200表示成功
this.message = '注册成功,即将跳转到登录页...';
this.messageType = 'success';
// 延迟2秒后跳转到登录页面
setTimeout(() => {
window.location.href = '/login'; // 页面跳转
}, 2000);
} else {
// 注册失败,显示后端返回的错误消息
this.message = response.data.msg;
this.messageType = 'error';
}
} catch (error) { // 捕获网络错误或服务器异常
this.message = '网络错误,请稍后重试';
this.messageType = 'error';
console.error('注册请求失败:', error); // 在控制台输出错误详情
} finally {
this.loading = false; // 无论成功失败,都结束加载状态
}
}
}
});
</script>
</body>
</html>
二、用户登录与退出
2.1 知识点概述
| 知识点 |
说明 |
| Session管理 |
Flask内置session基于cookie的会话机制,将用户登录状态存储在服务端session中 |
session['key'] = value |
设置session数据 |
session.pop('key', None) |
删除session数据(退出登录) |
session.get('key') |
读取session数据 |
| 密码校验 |
check_password_hash() 比对哈希值与明文密码 |
| 装饰器 |
自定义登录校验装饰器,避免在每个视图函数中重复编写登录检查逻辑 |
functools.wraps |
保留被装饰函数的元信息(函数名、文档字符串等) |
2.2 Flask Session配置要点
# app.py ------ Flask应用工厂中的Session配置
from flask import Flask
from flask_session import Session # 服务端Session扩展(可选,更安全)
import os
def create_app():
"""Flask应用工厂函数"""
app = Flask(__name__) # 创建Flask应用实例
# ===================== Session配置 =====================
# Flask内置session使用签名cookie,SECRET_KEY用于签名防篡改
app.config['SECRET_KEY'] = os.urandom(24).hex() # 生成随机24字节的十六进制密钥
# 以下是使用Flask-Session将session存储在服务端(如Redis)的配置
# app.config['SESSION_TYPE'] = 'redis' # session存储类型:redis
# app.config['SESSION_REDIS'] = Redis(host='localhost', port=6379) # Redis连接
# app.config['SESSION_PERMANENT'] = False # 关闭浏览器后session过期
# Session(app) # 初始化Flask-Session扩展
return app
2.3 登录接口实现
# views/user.py ------ 用户登录视图函数(续上文蓝图)
from flask import session # 导入session对象,用于管理用户登录状态
@user_bp.route('/login', methods=['POST']) # 登录接口,仅接受POST请求
def login():
"""
用户登录接口
请求方式:POST
请求体格式:JSON
{
"username": "zhangsan", # 用户名
"password": "123456" # 密码
}
"""
# ===================== 第一步:获取并校验请求数据 =====================
data = request.get_json() # 获取JSON请求体
if not data:
return jsonify({'code': 400, 'msg': '请求数据不能为空'})
username = data.get('username', '').strip() # 提取用户名并去除空格
password = data.get('password', '').strip() # 提取密码
if not username or not password: # 校验非空
return jsonify({'code': 400, 'msg': '用户名和密码不能为空'})
# ===================== 第二步:查询用户是否存在 =====================
user = User.query.filter_by( # 使用filter_by进行精确查询
username=username # 条件:username = 传入的用户名
).first() # 获取第一条记录
if not user: # 用户不存在
return jsonify({
'code': 401, # 401表示未授权(用户名不存在)
'msg': '用户名或密码错误' # 故意模糊提示,不暴露"用户名不存在"
})
# ===================== 第三步:校验密码 =====================
if not user.verify_password(password): # 调用User模型的密码验证方法
return jsonify({
'code': 401, # 密码错误也是401
'msg': '用户名或密码错误' # 同样模糊提示
})
# ===================== 第四步:设置Session,记录登录状态 =====================
session['user_id'] = user.id # 将用户ID存入session
session['username'] = user.username # 将用户名存入session
session.permanent = True # 设置session为持久化(受PERMANENT_SESSION_LIFETIME控制)
# ===================== 第五步:返回成功响应 =====================
return jsonify({
'code': 200,
'msg': '登录成功',
'data': user.to_dict() # 返回用户信息
})
2.4 退出登录接口
# views/user.py ------ 用户退出登录视图函数
@user_bp.route('/logout', methods=['POST']) # 退出接口,POST请求
def logout():
"""
用户退出登录接口
清除session中的用户信息,实现退出功能
"""
session.pop('user_id', None) # 删除session中的user_id,第二个参数为默认值
# 如果'user_id'不存在,返回None而不是抛出KeyError
session.pop('username', None) # 删除session中的username
# 也可以使用session.clear()一次性清除所有session数据
# session.clear()
return jsonify({
'code': 200, # 状态码200表示成功
'msg': '退出成功' # 提示信息
})
2.5 自定义登录校验装饰器
# utils/decorators.py ------ 自定义装饰器
from functools import wraps # 导入wraps装饰器,用于保留原函数的元信息
from flask import session, jsonify, redirect, url_for # 导入Flask组件
def login_required(f):
"""
登录校验装饰器
功能:在执行视图函数前,检查用户是否已登录
用法:@login_required 放在视图函数上方
:param f: 被装饰的视图函数
:return: 装饰后的函数
"""
@wraps(f) # 保留原函数f的__name__、__doc__等属性
def decorated_function(*args, **kwargs):
"""装饰器内部包装函数"""
user_id = session.get('user_id') # 从session中获取user_id
if not user_id: # 如果user_id不存在,说明未登录
# 判断请求类型
if request.is_json: # 如果是AJAX/JSON请求
return jsonify({
'code': 401, # 返回401未授权状态码
'msg': '请先登录' # 提示信息
})
else: # 如果是普通页面请求
return redirect( # 重定向到登录页面
url_for('user.login_page') # 使用url_for反向生成登录页URL
)
return f(*args, **kwargs) # 已登录则正常执行原视图函数
return decorated_function # 返回装饰后的函数
# ============ 使用装饰器的示例 ============
@user_bp.route('/profile', methods=['GET']) # 用户资料接口
@login_required # 应用登录校验装饰器
def get_profile():
"""获取当前登录用户的个人信息"""
user_id = session.get('user_id') # 从session获取当前用户ID
user = User.query.get(user_id) # 根据ID查询用户(get方法按主键查询)
if not user: # 如果用户被删除但session还在
session.clear() # 清除无效session
return jsonify({'code': 404, 'msg': '用户不存在'})
return jsonify({
'code': 200,
'data': user.to_dict() # 返回用户信息
})
2.6 前端登录页面
<!-- login.html ------ 用户登录页面 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户登录 - 智能租房</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</head>
<body>
<div id="app">
<h2>用户登录</h2>
<!-- @submit.prevent 阻止表单默认提交,改用JS控制 -->
<form @submit.prevent="handleLogin">
<div>
<label>用户名:</label>
<input type="text" v-model="username" placeholder="请输入用户名" required>
</div>
<div>
<label>密码:</label>
<input type="password" v-model="password" placeholder="请输入密码" required>
</div>
<button type="submit" :disabled="loading">
{{ loading ? '登录中...' : '立即登录' }}
</button>
<p v-if="message" :style="{ color: messageType === 'error' ? 'red' : 'green' }">
{{ message }}
</p>
<!-- 注册链接 -->
<p>还没有账号?<a href="/register">立即注册</a></p>
</form>
</div>
<script>
new Vue({
el: '#app',
data: {
username: '', // 用户名输入框绑定值
password: '', // 密码输入框绑定值
loading: false, // 加载状态
message: '', // 提示消息
messageType: '' // 消息类型
},
methods: {
/**
* 处理登录表单提交
*/
async handleLogin() {
// 前端基本校验
if (!this.username || !this.password) {
this.message = '用户名和密码不能为空';
this.messageType = 'error';
return; // 校验不通过,终止执行
}
this.loading = true; // 开始加载
this.message = ''; // 清空提示
try {
// 发送POST请求到登录接口
const response = await axios.post('/api/user/login', {
username: this.username, // 请求体中的用户名
password: this.password // 请求体中的密码
});
if (response.data.code === 200) { // 登录成功
this.message = '登录成功,正在跳转...';
this.messageType = 'success';
// 将用户信息保存到localStorage(可选,用于前端状态持久化)
localStorage.setItem(
'user_info', // localStorage的key
JSON.stringify(response.data.data) // 将对象序列化为JSON字符串
);
// 延迟跳转到用户中心页面
setTimeout(() => {
window.location.href = '/user/center';
}, 1500);
} else {
this.message = response.data.msg;
this.messageType = 'error';
}
} catch (error) {
this.message = '网络错误,请稍后重试';
this.messageType = 'error';
} finally {
this.loading = false; // 结束加载
}
}
}
});
</script>
</body>
</html>
三、用户中心页展示
3.1 知识点概述
| 知识点 |
说明 |
| Session读取 |
通过session.get('user_id')获取当前登录用户 |
| 数据库查询 |
使用query.get()按主键查询、query.filter_by()按条件查询 |
| 模板渲染 |
Jinja2模板引擎,使用render_template()渲染HTML模板 |
| 关联查询 |
查询用户的收藏列表、浏览记录等关联数据 |
| 分页查询 |
使用paginate()方法实现分页功能 |
3.2 后端实现
# views/user.py ------ 用户中心页面展示
from flask import render_template # 导入模板渲染函数
@user_bp.route('/center', methods=['GET']) # 用户中心页面路由
@login_required # 需要登录才能访问
def user_center():
"""
用户中心页面
展示用户基本信息、收藏列表、浏览记录等
"""
user_id = session.get('user_id') # 从session获取当前登录用户的ID
user = User.query.get(user_id) # 根据主键查询用户对象
if not user: # 用户不存在(可能被管理员删除)
session.clear() # 清除无效session
return redirect(url_for('user.login_page')) # 重定向到登录页
# ===================== 查询用户的收藏房源列表 =====================
# 假设存在House模型和收藏关联表UserFavor
from models import House, UserFavor # 导入房源模型和收藏模型
# 方法一:通过关联表查询(推荐,SQLAlchemy会自动优化JOIN)
favorites = db.session.query(House).join( # 联合查询House表和UserFavor表
UserFavor, # 关联的第二张表
UserFavor.house_id == House.id # JOIN条件:收藏表的house_id = 房源表的id
).filter(
UserFavor.user_id == user_id # WHERE条件:只查当前用户的收藏
).order_by( # 排序
UserFavor.create_time.desc() # 按收藏时间降序(最新的在前)
).limit(10).all() # 限制返回10条,.all()执行查询
# ===================== 查询用户的浏览记录 =====================
from models import BrowseHistory # 导入浏览记录模型
browse_history = BrowseHistory.query.filter_by( # 按条件查询浏览记录
user_id=user_id # 条件:用户ID等于当前用户
).order_by(
BrowseHistory.browse_time.desc() # 按浏览时间降序排列
).limit(20).all() # 最近20条浏览记录
# ===================== 渲染模板并传递数据 =====================
return render_template(
'user/center.html', # 模板文件路径(相对于templates目录)
user=user, # 传递用户对象到模板
favorites=favorites, # 传递收藏列表
browse_history=browse_history # 传递浏览记录
)
3.3 Jinja2模板实现
<!-- templates/user/center.html ------ 用户中心页面模板 -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>用户中心 - 智能租房</title>
</head>
<body>
<!-- ===================== 用户基本信息区域 ===================== -->
<div class="user-info">
<h2>个人中心</h2>
<!-- Jinja2变量输出语法:{{ 变量名 }} -->
<!-- 使用 管道过滤器 对输出内容进行转义,防止XSS攻击 -->
<div class="avatar">
<!-- 显示用户头像,若无头像则使用默认图片 -->
<img src="{{ user.avatar or '/static/images/default_avatar.png' }}"
alt="用户头像"
width="100">
</div>
<p><strong>用户名:</strong>{{ user.username | e }}</p>
<!-- | e 是escape过滤器的简写,将HTML特殊字符转义 -->
<p><strong>手机号:</strong>{{ user.phone or '未设置' }}</p>
<!-- or 过滤器:如果phone为None或空字符串,显示'未设置' -->
<p><strong>邮箱:</strong>{{ user.email or '未设置' }}</p>
<p><strong>注册时间:</strong>{{ user.create_time.strftime('%Y年%m月%d日') }}</p>
<!-- 调用Python对象的方法,格式化日期显示 -->
</div>
<!-- ===================== 收藏房源列表区域 ===================== -->
<div class="favorites">
<h3>我的收藏({{ favorites | length }}套)</h3>
<!-- | length 过滤器获取列表长度 -->
<!-- Jinja2条件判断 -->
{% if favorites %}
<!-- {% %} 是Jinja2的语法规则标签,用于控制逻辑 -->
<ul>
<!-- Jinja2循环语法 -->
{% for house in favorites %}
<!-- {% for ... %} ... {% endfor %} 循环遍历列表 -->
<li>
<a href="/house/detail/{{ house.id }}">{{ house.title }}</a>
<!-- 插入超链接,链接到房源详情页 -->
<span>{{ house.price }}元/月</span>
<!-- 显示月租金 -->
<span>{{ house.area }}㎡</span>
<!-- 显示面积 -->
<button onclick="cancelFavor({{ house.id }})">取消收藏</button>
<!-- 取消收藏按钮,点击触发JavaScript函数 -->
</li>
{% endfor %}
</ul>
{% else %}
<!-- else分支:收藏列表为空时显示 -->
<p>暂无收藏的房源</p>
{% endif %}
<!-- {% endif %} 结束if语句 -->
</div>
<!-- ===================== 浏览记录区域 ===================== -->
<div class="browse-history">
<h3>浏览记录({{ browse_history | length }}条)</h3>
{% if browse_history %}
<ul>
{% for record in browse_history %}
<li>
<a href="/house/detail/{{ record.house_id }}">
{{ record.house.title }}
</a>
<!-- 显示房源标题(通过关联关系获取) -->
<span>浏览时间:{{ record.browse_time.strftime('%Y-%m-%d %H:%M') }}</span>
<!-- 格式化显示浏览时间 -->
</li>
{% endfor %}
</ul>
<button onclick="clearHistory()">清空浏览记录</button>
{% else %}
<p>暂无浏览记录</p>
{% endif %}
</div>
<!-- ===================== 引入JavaScript ===================== -->
<script>
/**
* 取消收藏函数
* @param {number} houseId - 要取消收藏的房源ID
*/
function cancelFavor(houseId) {
// confirm() 弹出确认对话框,用户点击"确定"返回true
if (!confirm('确定取消收藏该房源吗?')) {
return; // 用户点击"取消"则不执行后续操作
}
// 使用fetch API发送DELETE请求
fetch('/api/user/favorite/' + houseId, { // 拼接URL,将houseId作为路径参数
method: 'DELETE', // HTTP方法:DELETE表示删除
headers: {
'Content-Type': 'application/json' // 指定请求头
}
})
.then(response => response.json()) // 将响应体解析为JSON(返回Promise)
.then(data => { // 处理解析后的数据
if (data.code === 200) { // 取消成功
alert('取消收藏成功'); // 弹出成功提示
location.reload(); // 刷新当前页面,重新加载数据
} else {
alert(data.msg); // 弹出错误提示
}
})
.catch(error => { // 捕获网络错误
console.error('请求失败:', error);
alert('操作失败,请稍后重试');
});
}
/**
* 清空浏览记录函数
*/
function clearHistory() {
if (!confirm('确定清空所有浏览记录吗?')) {
return;
}
fetch('/api/user/browse_history/clear', {
method: 'POST', // 使用POST方法
headers: { 'Content-Type': 'application/json' }
})
.then(response => response.json())
.then(data => {
if (data.code === 200) {
alert('浏览记录已清空');
location.reload(); // 刷新页面
} else {
alert(data.msg);
}
})
.catch(error => {
alert('操作失败');
});
}
</script>
</body>
</html>
四、账号信息修改
4.1 知识点概述
| 知识点 |
说明 |
request.files |
获取前端上传的文件(如头像) |
| 文件保存 |
使用secure_filename()安全处理文件名 + os.path.join()拼接路径 |
db.session.merge() |
合并对象到session(适用于从序列化/查询重新获取的对象) |
| UUID文件名 |
使用uuid.uuid4()生成唯一文件名,避免文件名冲突和安全风险 |
| 数据库更新 |
查询对象后直接修改属性,然后commit()即可自动UPDATE |
4.2 后端实现
# views/user.py ------ 账号信息修改
import os # 导入操作系统模块,用于文件路径操作
import uuid # 导入UUID模块,用于生成唯一文件名
from werkzeug.utils import secure_filename # 导入安全文件名处理函数
from flask import current_app # 导入current_app,用于获取应用配置
@user_bp.route('/profile/update', methods=['POST']) # 修改个人信息接口
@login_required # 需要登录
def update_profile():
"""
修改用户个人信息
支持修改:头像(文件上传)、手机号、邮箱
请求格式:multipart/form-data(因为包含文件上传)
"""
user_id = session.get('user_id') # 获取当前用户ID
user = User.query.get(user_id) # 查询用户对象
if not user:
return jsonify({'code': 404, 'msg': '用户不存在'})
# ===================== 处理头像上传 =====================
avatar_file = request.files.get('avatar') # 从请求的文件字段中获取头像文件
# request.files 是一个字典,存储所有上传的文件
if avatar_file and avatar_file.filename: # 如果确实上传了文件(且文件名不为空)
# --- 校验文件类型 ---
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} # 允许的图片扩展名集合
# 获取文件扩展名(小写),例如 "photo.JPG" -> "jpg"
file_ext = avatar_file.filename.rsplit('.', 1)[-1].lower() # 以最后一个.分割,取后半部分
if file_ext not in ALLOWED_EXTENSIONS: # 检查扩展名是否在允许列表中
return jsonify({'code': 400, 'msg': '头像格式不支持,请上传png/jpg/gif图片'})
# --- 生成唯一文件名 ---
# 使用UUID生成唯一文件名,避免中文文件名、特殊字符导致的问题
new_filename = f"{uuid.uuid4().hex}.{file_ext}"
# uuid4()生成随机UUID,.hex获取其32位十六进制字符串表示
# 例如:a1b2c3d4e5f6...abc.jpg
# --- 确定保存路径 ---
upload_folder = os.path.join( # 拼接上传目录的绝对路径
current_app.root_path, # 应用根目录
'static', # static子目录
'uploads', # uploads子目录
'avatars' # avatars子目录(专门存放头像)
)
# os.makedirs() 递归创建目录,exist_ok=True表示目录已存在时不报错
os.makedirs(upload_folder, exist_ok=True)
# --- 保存文件 ---
file_path = os.path.join(upload_folder, new_filename) # 完整的文件保存路径
avatar_file.save(file_path) # 将上传的文件保存到指定路径
# --- 更新用户的头像URL ---
user.avatar = f'/static/uploads/avatars/{new_filename}' # 存储相对URL路径
# ===================== 修改手机号 =====================
new_phone = request.form.get('phone', '').strip() # request.form 获取表单文本字段
if new_phone: # 如果提交了新手机号
import re # 导入正则模块
if not re.match(r'^1[3-9]\d{9}$', new_phone): # 校验手机号格式
return jsonify({'code': 400, 'msg': '手机号格式不正确'})
# 检查新手机号是否被其他用户使用
existing = User.query.filter( # 查询条件
User.phone == new_phone, # 手机号匹配
User.id != user_id # 排除当前用户自己
).first()
if existing: # 如果手机号已被其他用户使用
return jsonify({'code': 400, 'msg': '该手机号已被其他用户绑定'})
user.phone = new_phone # 更新手机号
# ===================== 修改邮箱 =====================
new_email = request.form.get('email', '').strip()
if new_email:
import re
if not re.match(r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$', new_email):
return jsonify({'code': 400, 'msg': '邮箱格式不正确'})
existing = User.query.filter(
User.email == new_email,
User.id != user_id
).first()
if existing:
return jsonify({'code': 400, 'msg': '该邮箱已被其他用户绑定'})
user.email = new_email
# ===================== 提交修改到数据库 =====================
try:
db.session.commit() # 提交事务,自动生成UPDATE SQL语句
return jsonify({
'code': 200,
'msg': '个人信息修改成功',
'data': user.to_dict() # 返回更新后的用户数据
})
except Exception as e:
db.session.rollback() # 失败时回滚
return jsonify({'code': 500, 'msg': f'修改失败:{str(e)}'})
4.3 前端头像上传示例
<!-- 头像上传表单(嵌入用户中心页面) -->
<div id="profile-app">
<h3>修改个人信息</h3>
<!-- 表单enctype必须设置为multipart/form-data才能上传文件 -->
<form @submit.prevent="updateProfile" enctype="multipart/form-data">
<!-- 头像预览 -->
<div>
<img :src="avatarPreview" <!-- :src动态绑定头像预览地址 -->
alt="头像预览"
width="80"
height="80"
style="border-radius: 50%;"> <!-- 圆形头像样式 -->
</div>
<!-- 头像文件选择框 -->
<div>
<label>更换头像:</label>
<input
type="file" <!-- 文件类型输入框 -->
accept="image/*" <!-- 只接受图片文件 -->
@change="onAvatarChange" <!-- 文件选择变化时触发事件处理函数 -->
>
</div>
<!-- 手机号 -->
<div>
<label>手机号:</label>
<input type="text" v-model="phone" placeholder="请输入手机号">
</div>
<!-- 邮箱 -->
<div>
<label>邮箱:</label>
<input type="email" v-model="email" placeholder="请输入邮箱">
</div>
<button type="submit" :disabled="loading">
{{ loading ? '保存中...' : '保存修改' }}
</button>
<p v-if="message" :style="{ color: msgType === 'error' ? 'red' : 'green' }">
{{ message }}
</p>
</form>
</div>
<script>
new Vue({
el: '#profile-app',
data: {
phone: '{{ user.phone or "" }}', // 使用Jinja2语法初始化手机号(服务端渲染)
email: '{{ user.email or "" }}', // 初始化邮箱
avatarPreview: '{{ user.avatar or "/static/images/default_avatar.png" }}',
avatarFile: null, // 存储用户选择的头像文件对象
loading: false,
message: '',
msgType: ''
},
methods: {
/**
* 头像文件选择变化处理函数
* @param {Event} event - 文件输入框的change事件对象
*/
onAvatarChange(event) {
const file = event.target.files[0]; // 获取用户选择的第一个文件
if (file) { // 如果选择了文件
this.avatarFile = file; // 保存文件对象到data中
// URL.createObjectURL() 创建一个临时URL用于预览
// 这样不需要上传就能在浏览器中预览图片
this.avatarPreview = URL.createObjectURL(file);
}
},
/**
* 提交个人信息修改
*/
async updateProfile() {
this.loading = true;
this.message = '';
// FormData用于构建multipart/form-data格式的请求体
// 这是上传文件必须使用的格式
const formData = new FormData();
// append()方法向FormData添加字段
if (this.avatarFile) { // 如果选择了新头像
formData.append('avatar', this.avatarFile); // 添加文件字段
}
formData.append('phone', this.phone); // 添加手机号字段
formData.append('email', this.email); // 添加邮箱字段
try {
const response = await axios.post(
'/api/user/profile/update', // 修改个人信息的API地址
formData, // 传入FormData对象
{
headers: {
// 注意:使用FormData时不要手动设置Content-Type
// axios会自动设置为 multipart/form-data 并添加boundary
// 手动设置会导致文件上传失败
}
}
);
if (response.data.code === 200) {
this.message = '修改成功';
this.msgType = 'success';
// 如果修改了头像,更新预览
if (response.data.data.avatar) {
this.avatarPreview = response.data.data.avatar;
}
} else {
this.message = response.data.msg;
this.msgType = 'error';
}
} catch (error) {
this.message = '网络错误';
this.msgType = 'error';
} finally {
this.loading = false;
}
}
}
});
</script>
五、收藏和取消收藏房源信息
5.1 知识点概述
| 知识点 |
说明 |
| 多对多关系 |
用户和房源是多对多关系,需要中间关联表(收藏表) |
db.relationship() |
SQLAlchemy的关系定义,简化关联查询 |
| RESTful设计 |
收藏用POST,取消收藏用DELETE,符合RESTful API规范 |
| 事务处理 |
收藏/取消操作需要保证原子性 |
5.2 收藏模型定义
# models.py ------ 收藏模型和房源模型
from datetime import datetime
class House(db.Model):
"""房源模型"""
__tablename__ = 'house'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='房源ID')
title = db.Column(db.String(256), nullable=False, comment='房源标题')
price = db.Column(db.Float, nullable=False, comment='月租金(元)')
area = db.Column(db.Float, comment='面积(平方米)')
address = db.Column(db.String(256), comment='详细地址')
rooms = db.Column(db.String(32), comment='户型,如:2室1厅')
image_url = db.Column(db.String(512), comment='房源主图URL')
create_time = db.Column(db.DateTime, default=datetime.now, comment='发布时间')
# 定义与User的多对多关系(通过UserFavor中间表)
# secondary指定中间表名
# backref定义反向引用:从User对象可以直接访问 user.favorites 获取收藏列表
# lazy='dynamic' 表示延迟加载,返回查询对象而非列表,支持后续链式查询
users = db.relationship(
'User', # 关联的目标模型
secondary='user_favor', # 中间关联表名
backref=db.backref('favorites', lazy='dynamic'), # 反向引用名和加载策略
lazy='dynamic' # 正向加载策略
)
class UserFavor(db.Model):
"""用户收藏关联表(多对多关系的中间表)"""
__tablename__ = 'user_favor'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='收藏记录ID')
user_id = db.Column( # 外键:关联用户表
db.Integer,
db.ForeignKey('user.id'), # 指定外键关联到user表的id字段
nullable=False,
comment='用户ID'
)
house_id = db.Column( # 外键:关联房源表
db.Integer,
db.ForeignKey('house.id'), # 指定外键关联到house表的id字段
nullable=False,
comment='房源ID'
)
create_time = db.Column( # 收藏时间
db.DateTime,
default=datetime.now,
comment='收藏时间'
)
# 联合唯一约束:同一用户对同一房源只能收藏一次
__table_args__ = (
db.UniqueConstraint( # 唯一约束
'user_id', # 字段1
'house_id', # 字段2
name='uq_user_house' # 约束名称
),
)
class BrowseHistory(db.Model):
"""浏览记录模型"""
__tablename__ = 'browse_history'
id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='记录ID')
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, comment='用户ID')
house_id = db.Column(db.Integer, db.ForeignKey('house.id'), nullable=False, comment='房源ID')
browse_time = db.Column(db.DateTime, default=datetime.now, comment='浏览时间')
# 定义与House的关系,方便通过 record.house 访问房源信息
house = db.relationship(
'House', # 关联的模型
backref=db.backref('browse_records', lazy='dynamic') # 反向引用
)
5.3 收藏/取消收藏后端接口
# views/user.py ------ 收藏相关接口
from models import House, UserFavor, BrowseHistory # 导入相关模型
@user_bp.route('/favorite/<int:house_id>', methods=['POST']) # 收藏接口,house_id为URL路径参数
@login_required
def add_favorite(house_id):
"""
收藏房源
:param house_id: 从URL路径中提取的房源ID(Flask自动转换为int类型)
"""
user_id = session.get('user_id') # 获取当前登录用户ID
# --- 检查房源是否存在 ---
house = House.query.get(house_id) # 根据主键查询房源
if not house: # 房源不存在
return jsonify({'code': 404, 'msg': '房源不存在'})
# --- 检查是否已经收藏过 ---
existing = UserFavor.query.filter_by( # 查询收藏记录
user_id=user_id, # 条件:当前用户
house_id=house_id # 条件:指定房源
).first()
if existing: # 已经收藏过了
return jsonify({'code': 400, 'msg': '您已收藏过该房源,无需重复收藏'})
# --- 创建收藏记录 ---
try:
favor = UserFavor( # 创建收藏记录实例
user_id=user_id, # 设置用户ID
house_id=house_id # 设置房源ID
)
db.session.add(favor) # 添加到session
db.session.commit() # 提交事务
return jsonify({
'code': 200,
'msg': '收藏成功'
})
except Exception as e:
db.session.rollback() # 回滚事务
return jsonify({'code': 500, 'msg': f'收藏失败:{str(e)}'})
@user_bp.route('/favorite/<int:house_id>', methods=['DELETE']) # 取消收藏接口
@login_required
def cancel_favorite(house_id):
"""
取消收藏房源
:param house_id: 要取消收藏的房源ID
"""
user_id = session.get('user_id') # 获取当前用户ID
# --- 查找收藏记录 ---
favor = UserFavor.query.filter_by(
user_id=user_id, # 条件:当前用户
house_id=house_id # 条件:指定房源
).first()
if not favor: # 没有找到收藏记录
return jsonify({'code': 404, 'msg': '未找到该收藏记录'})
# --- 删除收藏记录 ---
try:
db.session.delete(favor) # 从session中删除对象
db.session.commit() # 提交事务,执行DELETE SQL
return jsonify({
'code': 200,
'msg': '取消收藏成功'
})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'msg': f'取消收藏失败:{str(e)}'})
@user_bp.route('/favorite/<int:house_id>/check', methods=['GET']) # 检查是否已收藏
@login_required
def check_favorite(house_id):
"""
检查当前用户是否已收藏指定房源
用于前端收藏按钮的状态显示(已收藏/未收藏)
"""
user_id = session.get('user_id')
# exists() 子查询比first()更高效,因为它只检查是否存在,不需要返回完整对象
is_favorited = db.session.query( # 使用session.query()进行查询
UserFavor.query.filter_by( # 子查询
user_id=user_id,
house_id=house_id
).exists() # 生成 EXISTS 子查询
).scalar() # 执行查询并返回布尔值结果
return jsonify({
'code': 200,
'data': {
'is_favorited': is_favorited # True表示已收藏,False表示未收藏
}
})
六、用户浏览记录管理
6.1 知识点概述
| 知识点 |
说明 |
| 浏览记录去重 |
同一用户浏览同一房源时更新时间而非新增记录(使用first_or_404或filter_by().first()判断) |
| 批量删除 |
使用delete()批量删除记录,比逐条session.delete()更高效 |
| 时间格式化 |
strftime()方法将datetime对象转为指定格式字符串 |
db.and_() |
SQLAlchemy的AND条件构造器 |
6.2 后端实现
# views/user.py ------ 浏览记录管理接口
from datetime import datetime # 导入日期时间模块
@user_bp.route('/browse_history/add', methods=['POST']) # 添加浏览记录接口
@login_required
def add_browse_history():
"""
添加浏览记录
当用户访问房源详情页时,前端自动调用此接口记录浏览行为
如果同一房源已有浏览记录,则更新浏览时间(去重)
"""
user_id = session.get('user_id')
data = request.get_json()
house_id = data.get('house_id') # 从请求体获取房源ID
if not house_id: # 校验房源ID非空
return jsonify({'code': 400, 'msg': '房源ID不能为空'})
# --- 检查房源是否存在 ---
house = House.query.get(house_id)
if not house:
return jsonify({'code': 404, 'msg': '房源不存在'})
# --- 检查是否已有该房源的浏览记录 ---
existing = BrowseHistory.query.filter_by(
user_id=user_id, # 当前用户
house_id=house_id # 指定房源
).first()
try:
if existing: # 如果已有浏览记录
existing.browse_time = datetime.now() # 更新浏览时间为当前时间
db.session.commit() # 提交更新
return jsonify({
'code': 200,
'msg': '浏览记录已更新'
})
else: # 没有浏览记录,创建新的
history = BrowseHistory( # 创建浏览记录实例
user_id=user_id, # 用户ID
house_id=house_id # 房源ID
# browse_time 使用模型定义中的 default=datetime.now 自动填充
)
db.session.add(history) # 添加到session
db.session.commit() # 提交事务
return jsonify({
'code': 200,
'msg': '浏览记录添加成功'
})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'msg': f'操作失败:{str(e)}'})
@user_bp.route('/browse_history/clear', methods=['POST']) # 清空浏览记录接口
@login_required
def clear_browse_history():
"""
清空当前用户的所有浏览记录
使用批量删除,效率高于逐条删除
"""
user_id = session.get('user_id')
try:
# .delete() 直接在数据库层面执行DELETE语句
# 不需要先查询出所有对象再逐个删除,效率更高
num_deleted = BrowseHistory.query.filter_by( # 构建查询条件
user_id=user_id # 只删除当前用户的记录
).delete() # 执行批量删除,返回删除的行数
db.session.commit() # 提交事务
return jsonify({
'code': 200,
'msg': f'已清空{num_deleted}条浏览记录' # 提示删除了多少条记录
})
except Exception as e:
db.session.rollback()
return jsonify({'code': 500, 'msg': f'清空失败:{str(e)}'})
@user_bp.route('/browse_history', methods=['GET']) # 获取浏览记录列表接口
@login_required
def get_browse_history():
"""
获取当前用户的浏览记录
支持分页查询
查询参数:
page: 页码(默认1)
per_page: 每页数量(默认10)
"""
user_id = session.get('user_id')
# 获取分页参数,使用request.args获取URL查询字符串参数
# 例如:/api/user/browse_history?page=2&per_page=5
page = request.args.get('page', 1, type=int) # 页码,默认第1页,类型转为int
per_page = request.args.get('per_page', 10, type=int) # 每页数量,默认10条
# ===================== 使用paginate分页查询 =====================
# paginate()方法返回一个Pagination分页对象
pagination = BrowseHistory.query.filter_by(
user_id=user_id # 当前用户的记录
).order_by(
BrowseHistory.browse_time.desc() # 按浏览时间降序
).paginate( # 分页查询
page=page, # 当前页码
per_page=per_page, # 每页数量
error_out=False # 页码超出范围时不报404错误,返回空列表
)
# --- 序列化分页结果 ---
# pagination.items 获取当前页的数据列表
history_list = [] # 初始化结果列表
for record in pagination.items: # 遍历当前页的每条记录
history_list.append({ # 将每条记录转为字典
'id': record.id, # 浏览记录ID
'house_id': record.house_id, # 房源ID
'house_title': record.house.title, # 通过关系获取房源标题
'house_price': record.house.price, # 房源价格
'house_image': record.house.image_url, # 房源图片
'browse_time': record.browse_time.strftime( # 格式化浏览时间
'%Y-%m-%d %H:%M:%S'
)
})
# --- 返回分页数据 ---
return jsonify({
'code': 200,
'data': {
'list': history_list, # 当前页的数据列表
'total': pagination.total, # 总记录数
'page': pagination.page, # 当前页码
'per_page': pagination.per_page, # 每页数量
'pages': pagination.pages, # 总页数
'has_next': pagination.has_next, # 是否有下一页
'has_prev': pagination.has_prev # 是否有上一页
}
})
七、智能推荐(协同过滤算法)
7.1 知识点概述
| 知识点 |
说明 |
| 协同过滤(CF) |
基于用户行为数据的推荐算法,分为基于用户 和基于物品两类 |
| 皮尔逊相关系数 |
衡量两个变量之间线性相关程度的统计量,取值范围-1, 1 |
| 余弦相似度 |
通过向量夹角衡量相似性,常用于推荐系统 |
| 用户-物品矩阵 |
以用户为行、物品为列、评分为值的矩阵,是协同过滤的数据基础 |
| NumPy |
Python科学计算库,提供矩阵运算支持 |
| 数学公式推导 |
皮尔逊相关系数的计算公式和代码实现 |
7.2 协同过滤算法原理
核心思想:物以类聚,人以群分
基于用户的协同过滤(User-Based CF):
1. 找到与目标用户兴趣相似的"邻居用户"
2. 将邻居用户喜欢但目标用户未接触过的物品推荐给目标用户
基于物品的协同过滤(Item-Based CF):
1. 找到与用户喜欢的物品相似的其他物品
2. 将这些相似物品推荐给用户
7.3 皮尔逊相关系数
公式:
Σ(xi - x̄)(yi - ȳ)
r(X,Y) = ─────────────────────────
√[Σ(xi - x̄)²] × √[Σ(ȳ - yi)²]
其中:
xi, yi --- 两个用户对同一物品的评分
x̄, ȳ --- 两个用户的平均评分
r 的取值范围为 [-1, 1]
r > 0 正相关,r < 0 负相关,|r|越接近1相关性越强
7.4 算法实现代码
# utils/recommend.py ------ 协同过滤推荐算法实现
import numpy as np # 导入NumPy,用于高效的数值计算和矩阵运算
from collections import defaultdict # 导入defaultdict,自动初始化不存在的键的默认值
class CollaborativeFiltering:
"""
基于用户的协同过滤推荐算法
核心步骤:
1. 构建用户-房源评分矩阵
2. 计算用户间的皮尔逊相关系数(相似度)
3. 根据相似用户的偏好为目标用户推荐房源
"""
def __init__(self):
"""初始化方法"""
self.user_item_matrix = {} # 用户-物品评分矩阵(字典形式)
self.user_similarity = {} # 用户间相似度矩阵
def build_matrix(self, user_behaviors):
"""
构建用户-房源评分矩阵
评分规则(隐式反馈):
- 收藏房源:评分 = 5 分(表示高度兴趣)
- 浏览房源:评分 = 1 分(表示一般兴趣)
- 收藏 + 浏览:评分 = 5 分(收藏覆盖浏览)
:param user_behaviors: 用户行为数据列表
格式: [{'user_id': 1, 'house_id': 101, 'action': 'favor'}, ...]
action: 'favor' 表示收藏, 'browse' 表示浏览
"""
# 使用嵌套字典构建矩阵:{user_id: {house_id: score}}
matrix = defaultdict(lambda: defaultdict(float))
# defaultdict(float) 表示不存在的键默认值为0.0
for behavior in user_behaviors: # 遍历每条行为数据
user_id = behavior['user_id'] # 用户ID
house_id = behavior['house_id'] # 房源ID
action = behavior['action'] # 行为类型
if action == 'favor': # 收藏行为
matrix[user_id][house_id] = 5 # 评分设为5(高分)
elif action == 'browse': # 浏览行为
# 只有在没有更高评分(收藏)时才设为1
if matrix[user_id][house_id] < 1: # 当前评分低于1(即未评过分)
matrix[user_id][house_id] = 1 # 评分设为1
self.user_item_matrix = dict(matrix) # 转换为普通字典存储
def pearson_correlation(self, user1_ratings, user2_ratings):
"""
计算两个用户的皮尔逊相关系数
公式简化版:
r = Σ[(x_i - x̄)(y_i - ȳ)] / [√Σ(x_i - x̄)² × √Σ(y_i - ȳ)²]
:param user1_ratings: 用户1的评分字典 {house_id: score}
:param user2_ratings: 用户2的评分字典 {house_id: score}
:return: 皮尔逊相关系数,取值范围 [-1, 1]
"""
# --- 第一步:找到两个用户都评过分的房源(交集) ---
# set()创建集合,.keys()获取所有键(房源ID),& 取交集
common_houses = set(user1_ratings.keys()) & set(user2_ratings.keys())
# 如果没有共同评分的房源,无法计算相似度,返回0
if len(common_houses) == 0:
return 0.0
# --- 第二步:提取共同房源的评分数组 ---
# 将共同房源的评分提取为NumPy数组,便于向量化计算
ratings1 = np.array( # 用户1对共同房源的评分数组
[user1_ratings[h] for h in common_houses] # 列表推导式提取评分
)
ratings2 = np.array( # 用户2对共同房源的评分数组
[user2_ratings[h] for h in common_houses]
)
# --- 第三步:计算评分均值 ---
mean1 = np.mean(ratings1) # 用户1的平均评分(x̄)
mean2 = np.mean(ratings2) # 用户2的平均评分(ȳ)
# --- 第四步:计算分子(协方差部分) ---
# diff1 和 diff2 是评分与均值的差值数组
diff1 = ratings1 - mean1 # xi - x̄
diff2 = ratings2 - mean2 # yi - ȳ
numerator = np.sum(diff1 * diff2) # Σ(xi - x̄)(yi - ȳ)
# --- 第五步:计算分母(标准差乘积) ---
denominator = np.sqrt(np.sum(diff1 ** 2)) * np.sqrt(np.sum(diff2 ** 2))
# np.sum(diff1 ** 2) 计算 Σ(xi - x̄)²
# np.sqrt() 计算平方根
# --- 第六步:避免除零错误 ---
if denominator == 0: # 分母为0说明某个用户的评分都相同
return 0.0 # 返回0表示无相关性
# --- 第七步:计算并返回皮尔逊相关系数 ---
pearson_r = numerator / denominator # r = 分子 / 分母
# 将结果限制在 [-1, 1] 范围内(浮点运算可能产生微小误差)
return max(-1.0, min(1.0, pearson_r))
def compute_similarity(self, target_user_id):
"""
计算目标用户与所有其他用户的相似度
:param target_user_id: 目标用户ID
:return: 按相似度降序排列的用户列表 [(user_id, similarity), ...]
"""
if target_user_id not in self.user_item_matrix: # 目标用户不在矩阵中
return [] # 返回空列表
target_ratings = self.user_item_matrix[target_user_id] # 获取目标用户的评分数据
similarities = [] # 存储与其他用户的相似度
for user_id, ratings in self.user_item_matrix.items(): # 遍历所有用户
if user_id == target_user_id: # 跳过与自身的比较
continue
# 计算目标用户与当前用户的皮尔逊相关系数
similarity = self.pearson_correlation(
target_ratings, # 目标用户的评分
ratings # 当前用户的评分
)
# 只保留正相关的用户(相似度 > 0 的用户才有推荐价值)
if similarity > 0:
similarities.append((user_id, similarity)) # 添加(用户ID, 相似度)元组
# 按相似度降序排序(最相似的排在最前面)
similarities.sort(key=lambda x: x[1], reverse=True)
# key=lambda x: x[1] 表示按元组的第二个元素(相似度)排序
# reverse=True 表示降序
self.user_similarity[target_user_id] = similarities # 缓存计算结果
return similarities
def recommend(self, target_user_id, n=5):
"""
为目标用户推荐房源
推荐逻辑:
1. 找到与目标用户最相似的K个用户
2. 收集这些相似用户喜欢但目标用户未接触过的房源
3. 按加权评分排序,推荐评分最高的N个房源
:param target_user_id: 目标用户ID
:param n: 推荐房源数量,默认推荐5个
:return: 推荐的房源ID列表,按推荐度降序排列
"""
# --- 第一步:获取相似用户列表 ---
similarities = self.user_similarity.get( # 先查缓存
target_user_id,
self.compute_similarity(target_user_id) # 缓存未命中则实时计算
)
if not similarities: # 没有相似用户
return [] # 无法推荐
# --- 第二步:获取目标用户已接触过的房源集合 ---
target_houses = set( # 集合类型,便于后续做差集运算
self.user_item_matrix[target_user_id].keys()
)
# --- 第三步:计算推荐分数 ---
# 推荐分数 = Σ(邻居用户的评分 × 相似度) / Σ(相似度)
# 即相似用户评分的加权平均
recommendations = defaultdict(float) # {house_id: 推荐总分}
similarity_sum = defaultdict(float) # {house_id: 累计相似度(用于归一化)}
for neighbor_id, sim_score in similarities: # 遍历每个相似用户
neighbor_ratings = self.user_item_matrix[neighbor_id] # 获取该邻居的评分
for house_id, rating in neighbor_ratings.items(): # 遍历邻居评过的房源
if house_id not in target_houses: # 只推荐目标用户未接触过的房源
recommendations[house_id] += sim_score * rating
# 加权分数 = 相似度 × 评分
similarity_sum[house_id] += sim_score
# 累计相似度,用于后面归一化
# --- 第四步:归一化并排序 ---
# 归一化 = 加权总分 / 累计相似度,得到加权平均分
for house_id in recommendations: # 遍历所有候选房源
if similarity_sum[house_id] > 0: # 避免除零
recommendations[house_id] /= similarity_sum[house_id]
# 除以累计相似度,得到归一化的推荐分数
# 按推荐分数降序排序,取前N个
sorted_recommendations = sorted( # 排序
recommendations.items(), # 转为 [(house_id, score), ...] 列表
key=lambda x: x[1], # 按分数排序
reverse=True # 降序
)[:n] # 切片取前N个
# 只返回房源ID列表
return [house_id for house_id, score in sorted_recommendations]
# 列表推导式,提取每个元组的第一个元素(house_id)
7.5 智能推荐后端接口
# views/recommend.py ------ 智能推荐视图函数
from flask import Blueprint, session, jsonify
from models import db, User, House, UserFavor, BrowseHistory
from utils.recommend import CollaborativeFiltering # 导入协同过滤算法类
from views.user import login_required # 导入登录校验装饰器
# 创建推荐蓝图
recommend_bp = Blueprint(
'recommend',
__name__,
url_prefix='/api/recommend'
)
@recommend_bp.route('/houses', methods=['GET'])
@login_required
def get_recommendations():
"""
获取智能推荐房源列表
根据当前用户的收藏和浏览行为,使用协同过滤算法推荐相似房源
查询参数:
limit: 推荐数量,默认10
"""
user_id = session.get('user_id')
# ===================== 第一步:收集所有用户的行数据 =====================
user_behaviors = [] # 存储所有用户的行为数据
# --- 收集收藏数据 ---
favors = UserFavor.query.all() # 查询所有收藏记录
for favor in favors: # 遍历每条收藏记录
user_behaviors.append({ # 添加到行为列表
'user_id': favor.user_id, # 用户ID
'house_id': favor.house_id, # 房源ID
'action': 'favor' # 行为类型:收藏
})
# --- 收集浏览数据 ---
browses = BrowseHistory.query.all() # 查询所有浏览记录
for browse in browses: # 遍历每条浏览记录
user_behaviors.append({
'user_id': browse.user_id,
'house_id': browse.house_id,
'action': 'browse' # 行为类型:浏览
})
# ===================== 第二步:执行协同过滤推荐算法 =====================
cf = CollaborativeFiltering() # 创建协同过滤实例
cf.build_matrix(user_behaviors) # 构建用户-房源评分矩阵
limit = request.args.get('limit', 10, type=int) # 获取推荐数量参数
recommended_house_ids = cf.recommend( # 执行推荐算法
target_user_id=user_id, # 目标用户ID
n=limit # 推荐数量
)
# ===================== 第三步:查询推荐房源的详细信息 =====================
if not recommended_house_ids: # 如果没有推荐结果
# 降级策略:推荐最新的热门房源
recommended_houses = House.query.order_by( # 查询房源
House.create_time.desc() # 按发布时间降序
).limit(limit).all() # 取最新的N条
else:
# 根据推荐的ID列表查询房源详情
recommended_houses = House.query.filter( # 使用IN查询
House.id.in_(recommended_house_ids) # WHERE id IN (id1, id2, ...)
).all()
# 保持推荐算法给出的排序(因为IN查询不保证顺序)
house_map = {h.id: h for h in recommended_houses} # 创建ID到对象的映射字典
recommended_houses = [ # 按推荐顺序重新排列
house_map[hid] # 通过映射获取房源对象
for hid in recommended_house_ids # 按推荐ID列表的顺序遍历
if hid in house_map # 过滤掉可能不存在的ID
]
# ===================== 第四步:序列化并返回结果 =====================
result = []
for house in recommended_houses:
result.append({
'id': house.id,
'title': house.title,
'price': house.price,
'area': house.area,
'address': house.address,
'rooms': house.rooms,
'image_url': house.image_url
})
return jsonify({
'code': 200,
'data': {
'list': result, # 推荐房源列表
'total': len(result) # 推荐总数
}
})
7.6 推荐结果前端展示
<!-- recommend.html ------ 智能推荐页面 -->
<div id="recommend-app">
<h2>为您推荐</h2>
<!-- 推荐房源列表 -->
<div class="house-list">
<!-- v-for 循环渲染推荐房源卡片 -->
<div
class="house-card"
v-for="house in recommendList"
:key="house.id"
>
<!-- 房源图片 -->
<img :src="house.image_url || '/static/images/default_house.png'"
:alt="house.title">
<!-- :alt 动态绑定图片替代文本 -->
<!-- 房源信息 -->
<div class="house-info">
<h3>
<a :href="'/house/detail/' + house.id">{{ house.title }}</a>
<!-- :href 动态拼接详情页URL -->
</h3>
<p>{{ house.rooms }} | {{ house.area }}㎡</p>
<p class="price">{{ house.price }}元/月</p>
<p class="address">{{ house.address }}</p>
</div>
</div>
</div>
<!-- 空状态提示 -->
<div v-if="recommendList.length === 0 && !loading">
<p>暂无推荐,请多浏览和收藏房源以获取个性化推荐</p>
</div>
<!-- 加载状态 -->
<div v-if="loading">
<p>正在为您生成推荐...</p>
</div>
</div>
<script>
new Vue({
el: '#recommend-app',
data: {
recommendList: [], // 推荐房源列表
loading: true // 加载状态
},
// mounted生命周期钩子:Vue实例挂载到DOM后自动执行
mounted() {
this.fetchRecommendations(); // 页面加载完成后立即获取推荐
},
methods: {
/**
* 从后端获取推荐房源数据
*/
async fetchRecommendations() {
this.loading = true; // 开始加载
try {
const response = await axios.get( // GET请求
'/api/recommend/houses', // 推荐接口URL
{
params: { // URL查询参数
limit: 10 // 推荐10套房源
}
}
);
if (response.data.code === 200) { // 请求成功
this.recommendList = response.data.data.list; // 更新推荐列表
}
} catch (error) {
console.error('获取推荐失败:', error);
} finally {
this.loading = false; // 结束加载
}
}
}
});
</script>
八、综合知识点总结
8.1 Flask核心知识点汇总表
| 知识点分类 |
具体内容 |
对应功能 |
| 路由与蓝图 |
Blueprint、@route、methods、URL参数<int:id> |
所有接口 |
| 请求处理 |
request.get_json()、request.form、request.files、request.args |
注册/登录/修改信息 |
| 响应处理 |
jsonify()、render_template()、redirect() |
所有接口 |
| Session管理 |
session['key']、session.get()、session.pop()、session.clear() |
登录/退出/权限校验 |
| ORM操作 |
db.session.add()、db.session.commit()、db.session.delete()、db.session.rollback() |
增删改查 |
| 查询方法 |
query.get()、query.filter_by()、query.filter()、paginate() |
各种数据查询 |
| 关联查询 |
db.relationship()、backref、JOIN查询、lazy策略 |
收藏/浏览记录 |
| 文件上传 |
request.files、secure_filename()、uuid命名 |
头像上传 |
| 密码安全 |
generate_password_hash()、check_password_hash() |
注册/登录 |
| 装饰器 |
@wraps、@login_required、自定义装饰器模式 |
权限控制 |
| Jinja2模板 |
{``{ }}输出、{% %}逻辑、` |
过滤器、{% include %}` |
| 错误处理 |
try-except、db.session.rollback() |
数据安全 |
| 算法应用 |
协同过滤、皮尔逊相关系数、NumPy矩阵运算 |
智能推荐 |
8.2 关键Flask-SQLAlchemy操作速查
# ============ 增(Create)============
user = User(username='test') # 创建对象
db.session.add(user) # 添加到session
db.session.commit() # 提交到数据库
# 批量添加
db.session.add_all([user1, user2, user3]) # 一次添加多个对象
db.session.commit()
# ============ 查(Read)============
User.query.get(1) # 按主键查询
User.query.filter_by(username='test').first() # 精确条件查询,返回第一条
User.query.filter(User.username.like('%test%')).all() # 模糊查询
User.query.filter_by(is_active=True).order_by( # 带排序的条件查询
User.create_time.desc()
).paginate(page=1, per_page=10) # 分页查询
# ============ 改(Update)============
user = User.query.get(1) # 先查询
user.username = 'new_name' # 修改属性
db.session.commit() # 提交自动检测变更并生成UPDATE
# ============ 删(Delete)============
user = User.query.get(1) # 先查询
db.session.delete(user) # 标记删除
db.session.commit() # 提交执行DELETE
# 批量删除(更高效)
User.query.filter_by(is_active=False).delete() # 直接在数据库层面删除
db.session.commit()
# ============ 事务管理 ============
try:
db.session.add(some_object)
db.session.commit() # 成功则提交
except Exception as e:
db.session.rollback() # 失败则回滚
8.3 项目完整路由规划
# 路由总览 ------ 注册到Flask应用
def register_blueprints(app):
"""注册所有蓝图到Flask应用"""
from views.user import user_bp # 导入用户蓝图
from views.recommend import recommend_bp # 导入推荐蓝图
app.register_blueprint(user_bp) # 注册用户蓝图,前缀/api/user
app.register_blueprint(recommend_bp) # 注册推荐蓝图,前缀/api/recommend
# ===================== 路由表 =====================
# 用户模块路由 (/api/user)
# POST /api/user/register 用户注册
# POST /api/user/login 用户登录
# POST /api/user/logout 退出登录
# GET /api/user/profile 获取个人信息
# POST /api/user/profile/update 修改个人信息(含头像上传)
# POST /api/user/favorite/<house_id> 收藏房源
# DELETE /api/user/favorite/<house_id> 取消收藏
# GET /api/user/favorite/<house_id>/check 检查是否已收藏
# POST /api/user/browse_history/add 添加浏览记录
# POST /api/user/browse_history/clear 清空浏览记录
# GET /api/user/browse_history 获取浏览记录列表(分页)
# GET /api/user/center 用户中心页面
# 推荐模块路由 (/api/recommend)
# GET /api/recommend/houses 获取推荐房源列表