ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Flask+SQLAlchemy房屋租赁系统开发实战:数据库迁移、权限与部署

Flask+SQLAlchemy房屋租赁系统开发实战:数据库迁移、权限与部署 简介基于PythonFlask框架构建的房屋租赁系统项目是一套适合Web开发学习者与毕业设计选题者参考的完整工程包含系统源码与数据库初始化文件。项目围绕房源管理、租赁信息展示、后台维护等常见业务展开采用Flask-SQLAlchemy进行ORM映射配合Flask-WTF表单处理、Flask-Session会话管理可帮助读者理解一个轻量级Web应用从模型设计到视图渲染的完整流程。资源包共273个文件压缩后大小约3.07MB其中核心为35个Python源码文件承担路由、模型与业务逻辑另含14个HTML页面模板、100个JavaScript脚本和35个CSS样式表覆盖前端交互与界面布局附带SQL数据库脚本便于直接导入使用。整体目录结构清晰适合按模块逐步阅读。目前已有497人学习该资源通过运行项目可以直观看到房屋租赁系统的前后端联动效果既能作为Flask框架的综合练手案例也能在此基础上扩展新功能快速产出课程设计或实训作品。1. 为什么房屋租赁系统用 Flask 1.1 SQLAlchemy 而不是 Django一套租房后台看功能并不复杂真正打开源码才发现用户、房源、订单、图片、登录态全都要亲手组织。用 Django admin 生成管理端确实快但房源多条件筛选、房东角色权限、订单状态流转这些业务写进后台反而不如 Flask 1.1 SQLAlchemy 直来直去。这个项目把 Flask 应用最常见的拼装方式走完了一遍Flask-SQLAlchemy 管 ORMFlask-Migrate 管表结构版本Flask-Session 接 Redis 管登录态Flask-WTF 管表单与 CSRFPyMySQL 直连 MySQL。加上源码里带数据库文件很适合作为课程设计、毕业设计或者中小型租赁平台的二次开发基座。下面按数据库迁移、房源检索、会话权限三条线拆代码最后补部署阶段最容易翻车的几个填坑点。2. 项目结构与数据模型从租房业务到 Flask 蓝图2.1 应用工厂与蓝图划分先看入口。项目没有把所有路由堆在一个文件里而是用应用工厂create_app()把 Flask 实例延后创建再用蓝图把用户端、房源端、后台管理端拆开。这样做的直接收益是测试时可以传入不同配置创建单独实例业务模块之间也不会因为互相 import 而循环引用。# config.py import os import redis from datetime import timedelta class Config: SECRET_KEY os.environ.get(SECRET_KEY) or dev-change-me SQLALCHEMY_DATABASE_URI os.environ.get(DATABASE_URL) or \ mysqlpymysql://root:123456127.0.0.1:3306/house_rental?charsetutf8 SQLALCHEMY_TRACK_MODIFICATIONS False SESSION_TYPE redis SESSION_REDIS redis.StrictRedis(host127.0.0.1, port6379, db0) PERMANENT_SESSION_LIFETIME timedelta(hours8) MAX_CONTENT_LENGTH 3 * 1024 * 1024 UPLOAD_DIR os.path.join(os.path.dirname(__file__), app/static/uploads)# app/__init__.py from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_migrate import Migrate from flask_session import Session from config import Config db SQLAlchemy() migrate Migrate() def register_blueprints(app): from app.house.views import house_bp from app.user.views import user_bp from app.admin.views import admin_bp app.register_blueprint(house_bp, url_prefix/house) app.register_blueprint(user_bp, url_prefix/user) app.register_blueprint(admin_bp, url_prefix/admin) def create_app(config_classConfig): app Flask(__name__) app.config.from_object(config_class) db.init_app(app) migrate.init_app(app, db) Session(app) register_blueprints(app) return app第一段代码里SQLALCHEMY_DATABASE_URI的驱动写成了mysqlpymysql这是 Python 3 下面连 MySQL 最稳的写法charsetutf8一定不能省省掉之后插入中文会在写库时报乱码或者Incorrect string value。SESSION_TYPE和SESSION_REDIS是 Flask-Session 的配置这里已经提前把 Redis 接到登录态上了所以部署时除了 MySQL 还要保证 Redis 6379 端口可连。MAX_CONTENT_LENGTH限制上传体最大 3MB超过会抛413 RequestEntityTooLarge这是很多初学者想不到的一层防护。第二段代码的关键在register_blueprints里的url_prefix。/house、/user、/admin三个前缀把路由空间隔离了后面写视图时house_bp.route(/list)实际访问路径是/house/list。这里仍然在函数内部 import 蓝图是为了等app实例创建后再加载视图避免模块级from app import db时相互拉扯。2.2 用户、房源、订单三张核心表的模型设计房源租赁业务往下拆核心对象就是三个谁在租、租什么、交易状态怎么记录。源码里的数据库模型也是围绕这三张主表展开的。表名业务作用关键字段关系user租客、房东、管理员id, phone, password_hash, role一对多房源house在租/已租房源信息id, user_id, title, district, price, status外键指向 userorder租赁订单/预约记录id, house_id, user_id, status外键指向 house 和 userhouse_image房源多图id, house_id, url外键指向 house这里的 role 不是字符串而是 SmallInteger我一般建议固定一个小型字典1 租客、2 房东、3 管理员。字符串角色看着可读性好但在数据库里容易写错、比对慢数字常量配合代码里注释反而更稳。from datetime import datetime class User(db.Model): __tablename__ user id db.Column(db.Integer, primary_keyTrue) phone db.Column(db.String(11), uniqueTrue, nullableFalse) password_hash db.Column(db.String(128), nullableFalse) role db.Column(db.SmallInteger, default1) # 1租客 2房东 3管理员 avatar db.Column(db.String(255)) houses db.relationship(House, backrefowner, lazydynamic) class House(db.Model): __tablename__ house id db.Column(db.Integer, primary_keyTrue) user_id db.Column(db.Integer, db.ForeignKey(user.id), nullableFalse) title db.Column(db.String(100), nullableFalse) district db.Column(db.String(50), indexTrue) area db.Column(db.Numeric(6, 2)) price db.Column(db.Numeric(10, 2), nullableFalse) status db.Column(db.SmallInteger, default0) # 0在租 1已租 created_at db.Column(db.DateTime, defaultdatetime.now) images db.relationship(HouseImage, backrefhouse, lazydynamic) class HouseImage(db.Model): __tablename__ house_image id db.Column(db.Integer, primary_keyTrue) house_id db.Column(db.Integer, db.ForeignKey(house.id), nullableFalse) url db.Column(db.String(255), nullableFalse)这段模型里有几个细节值得细看。User.houses用了lazydynamic这意味着user.houses返回的不是列表而是查询对象后面可以继续.filter()或.paginate()对大房源量的用户更安全代价是每次访问都会产生一次额外查询。House.created_at的默认值写的是datetime.now而不是datetime.now()注意没有括号模型类在导入时只绑定一次函数引用每条记录创建时才会执行取当前时间如果写了括号就成了类加载时固定一个时间戳所有房源会共用同一个创建时间。password_hash字段长度 128 是给 werkzeug 的generate_password_hash准备的不要节省长度。price和area用Numeric而不是Float是因为租金涉及金额浮点在边界上会出精度问题Numeric(10,2)在 MySQL 里对应DECIMAL(10,2)展示和计算都更可控。2.3 数据库迁移Flask-Migrate 与 alembic 的同步拿到资源包后第一件事不是直接跑主程序而是把数据库先提起来。项目里用 Flask-Migrate alembic 管理表结构启动命令挂在 Flask-Script 的db子命令下manage.py 里通过MigrateCommand完成注册python manage.py db init python manage.py db migrate -m create user/house/order tables python manage.py db upgrade第一条db init在 migrations 目录下生成 alembic 环境第二条migrate让 SQLAlchemy 把自己维护的元数据与当前数据库做对比自动生成一个迁移脚本第三条upgrade才真正执行建表。很多新手只跑前两步发现表没建出来就是因为忘了upgrade。如果之前已经手动执行过源码里的 SQL 文件再用db upgrade会撞上table already exists这种情况不是重来而是用db stamp打版本号第五节会专门说。这一节的迁移脚本放在migrations/versions下文件内同时包含upgrade()和downgrade()两个函数前者建表后者删表实现了表结构的版本回退。3. 房源发布与检索表单、查询分页与图片压缩3.1 Flask-WTF 表单与 CSRF 校验房源发布页面看似只要填标题、面积、价格、描述但直接手写request.form.get(price)在后期需求变更时会写出一堆判断。项目里用 Flask-WTF 的FlaskForm把字段和校验规则集中在一个类里from flask_wtf import FlaskForm from wtforms import StringField, FloatField, TextAreaField, SubmitField from wtforms.validators import DataRequired, Length, NumberRange class HouseForm(FlaskForm): title StringField(房源标题, validators[DataRequired(), Length(max100)]) area FloatField(面积, validators[NumberRange(min1, max500)]) price FloatField(月租金, validators[DataRequired(), NumberRange(min100)]) desc TextAreaField(描述, validators[Length(max2000)]) submit SubmitField(发布)FlaskForm和原生Form的区别在于它自动开启了 CSRF 防护。模板里必须渲染form.hidden_tag()它输出一个随机的csrf_token隐藏域提交时 Flask-WTF 会从请求体和 session 中校验 token校验不过直接返回 400。如果跳过表单类自己在视图里接收参数这一层防护就没了这是资源里值得保留的写法。发布视图拿到表单后流程是校验、组装模型、落库house_bp.route(/house/publish, methods[GET, POST]) def publish(): form HouseForm() if form.validate_on_submit(): house House( user_idsession[uid], titleform.title.data, areaform.area.data, priceform.price.data, status0 ) db.session.add(house) db.session.commit() return redirect(url_for(house.detail, house_idhouse.id)) return render_template(publish.html, formform)validate_on_submit会先判断请求方法是 POST再跑一遍字段校验所以 GET 时直接走到渲染模板的分支。字段值通过form.title.data取出form.validate()里已经做了类型转换price不再是字符串而是 float。权限装饰器在下一章的角色控制部分统一说明发布接口只需要在房东那侧补上角色拦截。3.2 条件筛选与分页filter、filter_by 与 paginate房源列表页是这类系统里查询负担最重的接口。常见的筛选条件是行政区域、价格上限和关键词项目里用一条可链式构造的 query 完成from flask import request from sqlalchemy import or_ def search(): page request.args.get(page, default1, typeint) per_page request.args.get(per_page, default10, typeint) district request.args.get(district, ) price_max request.args.get(price_max, typeint) keyword request.args.get(keyword, ).strip() query House.query.filter_by(status0) if district: query query.filter(House.district district) if price_max: query query.filter(House.price price_max) if keyword: query query.filter(or_( House.title.like(%{}%.format(keyword)), House.district.like(%{}%.format(keyword)) )) pagination query.order_by(House.created_at.desc()).paginate( pagepage, per_pageper_page, error_outFalse ) return pagination参数对应关系参数传入值作用缺失表现page页码 int分页页码默认 1per_page每页条数 int分页大小默认 10district区域名称 str精确匹配不过滤price_maxint价格上限不过滤keywordstr标题/区域模糊搜索不过滤这里filter_by(status0)和filter(House.status 0)效果一样但写法上filter_by适合等值条件filter可以写任意表达式所以区域和价格上限统一走filter。like(%keyword%)前后都有百分号MySQL 在这种写法下没法前缀匹配索引数据量到几十万以后会明显变慢项目规模下可以暂时接受但如果继续迭代得换成ngram全文索引或者把关键词独立成表。paginate(error_outFalse)很关键默认在页码超出范围时会抛 404这里关掉后返回空列表前端直接展示“没有更多房源”而不是跳错页。3.3 图片上传与 Pillow 尺寸压缩房源图片是租客判断房源的第一入口但手机原图动辄 3-5MB直接扔进static/uploads会同时拖垮磁盘和页面加载速度。依赖里的Pillow6.2.1就是用来压缩的。常见做法是把图片统一处理成最长边不超过 800px、质量 85 的 JPEGimport uuid import os from PIL import Image def save_house_image(file): if not file or not file.filename: return None img Image.open(file.stream) img img.convert(RGB) img.thumbnail((800, 800)) filename {}.jpg.format(uuid.uuid4().hex) save_path os.path.join(app.config[UPLOAD_DIR], filename) img.save(save_path, JPEG, quality85) return filenameImage.open接受文件流不需要先落盘再打开。convert(RGB)是为了处理 PNG 带透明通道的情况如果不转JPEG 编码会报cannot write mode RGBA as JPEG。thumbnail((800, 800))只会等比缩小、不会把小图放大所以原图 500px 的缩略图仍然是 500px。quality85是体积和观感比较平衡的值继续降到 70 能看到明显马赛克。文件名用uuid.uuid4().hex而不是原文件名是为了避免中文文件名的编码问题也防止用户上传同名文件互相覆盖。这里的app.config[MAX_CONTENT_LENGTH]与代码形成双层保护请求体超 3MB 时 Flask 直接拒绝Pillow 只处理已经进到视图里的文件。处理完图片后别忘记把filename写进house_image表否则下次刷新丢图片。4. 登录态与角色权限Flask-Session、Redis 与订单状态控制4.1 Flask-Session 把 session 从 Cookie 迁到 RedisFlask 默认的 session 是把数据做签名后直接存在客户端 Cookie 里数据本身不加密Base64 解开就能看到内容。登录用户的信息如果塞进 sessioncookie 体积会涨到几 KB每次请求都要带着走并且用户能读到自己的 role 字段虽然改不了。项目里接入了 Flask-Session 并把存储类型设为 redis这样客户端只保存一个随机的 session id用户真实数据落在服务端# config.py import redis from datetime import timedelta SESSION_TYPE redis SESSION_REDIS redis.StrictRedis(host127.0.0.1, port6379, db0) SESSION_KEY_PREFIX house:session: SESSION_PERMANENT True PERMANENT_SESSION_LIFETIME timedelta(hours8)登录视图往session里写数据的方式和默认 session 完全一样Flask-Session 通过替换flask.session的底层实现把值序列化后存进 Redisuser_bp.route(/user/login, methods[POST]) def login(): phone request.form.get(phone, ).strip() password request.form.get(password, ) user User.query.filter_by(phonephone).first() if user and check_password_hash(user.password_hash, password): session[uid] user.id session[role] user.role return redirect(url_for(main.index)) return render_template(login.html, error手机号或密码错误), 401session[uid]和session[role]在 Redis 里的 key 会带上前缀house:session:避免和同台机器上其它应用互相覆盖。PERMANENT_SESSION_LIFETIME决定了登录态默认存活时间这里设 8 小时。注意依赖里redis3.3.8是 Python 客户端的版本跟服务器端 redis-server 版本无关如果登录时报ConnectionError先用redis-cli ping确认服务端还活着。4.2 装饰器实现角色权限控制前端把“发布房源”按钮按 role 隐藏只是视觉层的事直接请求接口照样能访问。这在租房平台是个真实风险租客绕过后台接口给自己房源上架房东越权改别人房源。资源里的权限控制用一个装饰器统一收口from functools import wraps from flask import session, abort def roles_required(*roles): def decorator(fn): wraps(fn) def wrapped(*args, **kwargs): if session.get(role) not in roles: abort(403) return fn(*args, **kwargs) return wrapped return decorator使用方式是在路由上声明允许的角色编号house_bp.route(/house/publish, methods[GET, POST]) roles_required(2, 3) # 仅房东和管理员 def publish(): ...这段代码的重点在abort(403)而不是返回一个{code: 403}。403 状态码会让浏览器的开发面板和运维日志更直观前端也能统一拦截跳转登录页。仅靠session[role]还不够房东修改房源时还要校验资源归属常见做法是再加一层对象权限判断house_bp.route(/house/int:house_id/edit) roles_required(2, 3) def edit_house(house_id): house House.query.get_or_404(house_id) if house.user_id ! session.get(uid) and session.get(role) ! 3: abort(403) ...这里house.user_id ! session[uid]保证了普通房东只能操作自己发布的房源管理员 role3 不受限制。两个条件用 and 连起来就会把“非本房源房东”和“非管理员”两种情况都挡在 403 之外。4.3 下单周期的原子性与订单状态流转房源一旦被下单status 要从 0 变 1否则两个租客会同时看到“在租”并重复下单。项目里的核心操作是“查状态 更新状态”这一步必须在一个事务里完成user_bp.route(/order/create, methods[POST]) def create_order(): house_id request.form.get(house_id, typeint) if not house_id: abort(400) house House.query.filter( House.id house_id, House.status 0 ).first() if not house: return 房源已被租走, 409 house.status 1 db.session.add(Order(house_idhouse.id, user_idsession[uid], status0)) db.session.commit()这段逻辑在单进程开发环境下没有问题但两个请求并发进入时后一个请求读到的status可能仍是 0最终两个订单都创建成功。更稳的写法是直接执行原子 UPDATE 然后检查影响行数from app import db if db.session.execute( UPDATE house SET status1 WHERE id:id AND status0, {id: house_id} ).rowcount 0: return 房源已被租走, 409 order Order(house_idhouse_id, user_idsession[uid], status0) db.session.add(order) db.session.commit()订单的状态最好在模型里用常量约束避免散落的魔法数字状态码含义触发动作0待支付/待确认用户下单、房东确认1已确认/进行中合同履行中2已结束退租、结算3已取消房源重新置为在租状态迁移建议只由固定的几个服务函数驱动不要让视图层随意把字段改成任意值。查询高频的房源列表在status和id上建组合索引能避免这个表在数据量上来之后变成慢查询源头mysql ALTER TABLE house ADD INDEX idx_status_id (status, id);5. 部署排错PyMySQL 连接串、静态文件 404 和迁移回滚5.1 PyMySQL 连接串的正确写法Python 3 的环境里没有 MySQLdb直接写mysql://root:passlocalhost/house_rental很可能在启动时撞上ModuleNotFoundError: No module named MySQLdb。资源里的依赖已经列出了PyMySQL0.9.3正确的做法是在入口文件顶部做一次兼容注入import pymysql pymysql.install_as_MySQLdb()然后在配置里把驱动写完整mysqlpymysql://root:123456127.0.0.1:3306/house_rental?charsetutf8。这里mysqlpymysql是 SQLAlchemy 的方言写法install_as_MySQLdb()只是让遗留代码能 import MySQLdb两者一起用最稳妥。忘记charsetutf8的典型表现是中文乱码常见误用是把charset写成utf-8MySQL 驱动不认这个值。5.2 静态文件 404 的排查登录页打开后没有样式F12 看bootstrap.css返回 404第一反应去看 blueprint 的url_prefix实际上静态文件和蓝图路由没什么关系。真正原因是模板里把路径写死成了/static/css/bootstrap.min.css。应用部署在子路径或反向代理下时根路径变了就会挂。改成 Flask 推荐的写法link relstylesheet href{{ url_for(static, filenamecss/bootstrap.min.css) }}url_for(static, ...)会自动拼上应用的根路径所以即使以后挂到/house/或者加了 prefix路径也不会断。验证命令curl -I http://127.0.0.1:5000/static/css/bootstrap.min.css返回 200 再看页面仍 404 就检查app/static/css目录是否真的存在很多项目把文件放进了app/templates/static或者static_bak路径对不上就是 404。5.3 迁移脚本回滚与 stamp 标记手工建过表再跑db upgrade会报table already exists这时回滚不解决问题正确做法是先看当前版本python manage.py db current如果当前版本是空而数据库里已经有表用stamp把目标版本写到alembic_version表让 alembic 认为表结构已经处于该版本状态python manage.py db stamp revision_id如果想要回退表结构用downgradepython manage.py db downgrade revision_iddowngrade依赖迁移脚本里的downgrade()函数db migrate自动生成的脚本正常情况下都带所以回退前先确认upgrade和downgrade两个方向都动过不能只相信migrate生成的文件名。db current和downgrade配合至少能把误操作恢复到上一个可用的表结构版本。本文还有配套的精品资源点击获取
返回列表