Flask入门学习教程,从入门到精通,Flask智能租房——用户中心知识点详解(9)

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)数据库模型定义
python 复制代码
# 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)注册接口后端实现
python 复制代码
# 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)前端注册页面实现
html 复制代码
<!-- 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配置要点

python 复制代码
# 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 登录接口实现

python 复制代码
# 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 退出登录接口

python 复制代码
# 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 自定义登录校验装饰器

python 复制代码
# 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 前端登录页面

html 复制代码
<!-- 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 后端实现

python 复制代码
# 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模板实现

html 复制代码
<!-- 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 后端实现

python 复制代码
# 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 前端头像上传示例

html 复制代码
<!-- 头像上传表单(嵌入用户中心页面) -->

<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 收藏模型定义

python 复制代码
# 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 收藏/取消收藏后端接口

python 复制代码
# 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_404filter_by().first()判断)
批量删除 使用delete()批量删除记录,比逐条session.delete()更高效
时间格式化 strftime()方法将datetime对象转为指定格式字符串
db.and_() SQLAlchemy的AND条件构造器

6.2 后端实现

python 复制代码
# 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 算法实现代码

python 复制代码
# 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 智能推荐后端接口

python 复制代码
# 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 推荐结果前端展示

html 复制代码
<!-- 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@routemethods、URL参数<int:id> 所有接口
请求处理 request.get_json()request.formrequest.filesrequest.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()backrefJOIN查询lazy策略 收藏/浏览记录
文件上传 request.filessecure_filename()uuid命名 头像上传
密码安全 generate_password_hash()check_password_hash() 注册/登录
装饰器 @wraps@login_required、自定义装饰器模式 权限控制
Jinja2模板 {``{ }}输出、{% %}逻辑、` 过滤器、{% include %}`
错误处理 try-exceptdb.session.rollback() 数据安全
算法应用 协同过滤、皮尔逊相关系数、NumPy矩阵运算 智能推荐

8.2 关键Flask-SQLAlchemy操作速查

python 复制代码
# ============ 增(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 项目完整路由规划

python 复制代码
# 路由总览 ------ 注册到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           获取推荐房源列表

相关推荐
MageGojo1 小时前
做节日活动页时,如何用 API 快速生成对联内容
javascript·python·节日·对联生成
l1t1 小时前
DeepSeek总结的使用实体-组件-系统和基于存在性处理进行Python编程15-17
开发语言·数据库·python
魔法阵维护师2 小时前
从零开发游戏需要学习的c#模块,第三十一章(技能冷却系统 —— 范围爆炸)
学习·游戏·c#
河阿里2 小时前
Python数据可视化:Matplotlib从入门到精通
python·信息可视化·matplotlib
试剂界的爱马仕2 小时前
《古董局·终局5:潮生》第 4 章:藤田的棋局
人工智能·学习
searchforAI2 小时前
我的Obsidian知识库,现在可以自动剪藏笔记到本地了
人工智能·笔记·学习·音视频·ai工具·obsidian·视频总结
麻雀飞吧2 小时前
2026年期货量化入门路径:主流平台学习曲线与卡点观察
python
TechWayfarer2 小时前
IP数据接口调用示例:社交软件如何做同城匹配与用户画像分析
python·网络协议·tcp/ip·社交电子