Flask 房屋租赁系统:首页轮播与房源搜索实现方案
项目背景与需求概述
在本模块中,我们需要构建租房平台的两大核心页面:首页与搜索页。首页主要负责展示登录用户信息以及热门推荐的房源图片;搜索页则需支持多维度筛选(如区域、日期、排序),并具备无限滚动加载的能力。为了优化性能,后端将引入 Redis 缓存机制来存储热点数据。
首页功能开发
进入门户页面的主要交互目标是展示当前用户的状态(未登录显示登录框,已登录显示昵称),同时在首屏通过轮播图形式呈现预订量最高的前 5 套房源。

服务端接口设计
在 `api_1_0/houses.py` 路由文件中定义获取首页房源的逻辑。该接口需要优先从缓存读取数据,若缓存失效则查询数据库并更新缓存。
from flask import jsonify, current_app
import json
import redis # 假设已有连接对象 conn
from models import Property, constants
from utils.ret_code import RET, ret_msg
def fetch_homepage_listings():
"""获取首页热门房源列表"""
cache_slot = 'hot_listings_cache'
cached_data = None
# 尝试从 Redis 获取缓存
try:
raw_data = conn.get(cache_slot)
if raw_data:
cached_data = raw_data.decode()
except Exception as err:
current_app.logger.error(f"Redis 读取错误: {err}")
if not cached_data:
# 缓存未命中,查询数据库
try:
properties = Property.query \
.filter(Property.main_image != None) \
.order_by(Property.booking_count.desc()) \
.limit(constants.HOME_MAX_ITEMS).all()
result_set = []
for prop in properties:
result_set.append({
'prop_id': prop.id,
'name': prop.title,
'thumb': prop.main_image,
'cost': prop.price
})
cached_data = json.dumps(result_set)
# 写入缓存,设置过期时间
conn.setex(cache_slot, constants.CACHE_EXPIRE_TIME, cached_data)
except Exception as err:
current_app.logger.error(f"数据库查询异常: {err}")
return jsonify(code=RET.DBERR, msg='房源数据加载失败'), 500
return jsonify(code=RET.SUCCESS, msg=ret_msg[RET.SUCCESS], result=cached_data)
客户端交互逻辑
前端页面 `index.html` 对应的脚本需处理三个独立的数据请求:会话校验、房源列表、城区数据。由于依赖异步数据,DOM 操作必须放置在回调函数内部。
$(function() {
// 1. 验证用户登录状态
$.get('/api/v1.0/user/current', function(resp) {
if (resp.code === 0) {
$('.user-nick').text(resp.result.name);
$('.login-module').hide();
$('.profile-module').fadeIn();
} else {
$('.login-entry').show();
}
});
// 2. 加载热门房源并初始化轮播
$.get('/api/v1.0/homes/recommend', function(data) {
if (data.code === 0) {
const listContainer = $('#slider-content');
listContainer.html(template('listing-card', { items: data.result }));
// 此时 DOM 已渲染完成,再初始化插件
const swiperInstance = new Swiper('.main-slider', {
loop: true,
autoplay: { delay: 2500 },
pagination: '.swiper-pagination'
});
} else {
alert(data.msg);
}
});
// 3. 加载区域下拉菜单
$.get('/api/v1.0/zones/list', function(zones) {
if (zones.code === 0) {
const zoneHtml = template('zone-select', { zones: zones.result });
$('#city-selector ul').html(zoneHtml);
// 绑定选择事件
$('#city-selector li').on('click', function() {
const selectedName = $(this).text();
const selectedId = $(this).attr('data-id');
// 更新按钮显示及隐藏属性
$('#search-trigger').html(selectedName);
$('#search-trigger').attr('data-area-id', selectedId);
$('#search-trigger').attr('data-area-name', selectedName);
$('#city-popup').modal('hide');
});
}
});
});
// 跳转至搜索入口
function navigateToSearch(btnElement) {
const baseUrl = '/search.html';
let params = '?area=' + $(btnElement).attr('data-area-id');
params += '&areaName=' + ($(btnElement).attr('data-area-name') || '');
params += '&checkIn=' + $(btnElement).attr('data-start-date');
params += '&checkOut=' + $(btnElement).attr('data-end-date');
window.location.href = baseUrl + params;
}
搜索结果页实现
当用户执行搜索后,URL 携带筛选参数,例如:/search.html?aid=1&aname=朝阳区&sd=2023-10-01&ed=2023-10-05。页面顶部保留筛选器以便二次调整。

后端搜索算法与缓存策略
搜索接口 /search/houses 需要处理复杂的排他逻辑(排除已被预订的时间段)。为提高响应速度,采用 Redis Hash 结构存储分页结果,以"查询条件组合"作为 Key,"页码"作为 Field。
@app.route('/api/v1.0/search/houses')
def search_available_properties():
# 解析请求参数
req_area_id = request.args.get('aid')
req_start = request.args.get('sd')
req_end = request.args.get('ed')
req_page = int(request.args.get('page', 1))
req_sort = request.args.get('sorted_by', 'latest')
# 构建唯一的缓存键值,不包含页码以保证分页数据原子过期
cache_hash_key = f"search:{req_area_id}:{req_start}:{req_end}:{req_sort}"
try:
cached_res = conn.hget(cache_hash_key, str(req_page)).decode()
except Exception:
cached_res = None
if not cached_res:
# --- 数据库查询逻辑 ---
# 1. 验证基础数据类型
try:
if req_start: req_start_date = datetime.strptime(req_start, '%Y-%m-%d').date()
else: req_start_date = None
if req_end: req_end_date = datetime.strptime(req_end, '%Y-%m-%d').date()
else: req_end_date = None
except ValueError:
return jsonify(code=RET.PARAMERR, msg='日期格式无效'), 400
# 2. 构造排除订单的条件列表
exclude_filters = [Order.status == 'completed']
# 如果存在时间范围,需排除冲突订单
if req_start_date and req_end_date:
# 时间段重叠判断逻辑
exclude_filters.append(Order.start_time <= req_end_date)
exclude_filters.append(Order.end_time >= req_start_date)
elif req_start_date:
exclude_filters.append(Order.end_time >= req_start_date)
elif req_end_date:
exclude_filters.append(Order.start_time <= req_end_date)
# 3. 先查被占用的房源 ID
booked_ids = Order.query.filter(*exclude_filters).with_entities(Order.prop_id).all()
booked_ids = [bid[0] for bid in booked_ids] if booked_ids else []
# 4. 主查询:过滤可用房源
prop_query = Property.query.filter(
Property.area_id == (int(req_area_id) if req_area_id else None),
~Property.id.in_(booked_ids)
)
# 5. 应用排序规则
if req_sort == 'price_asc':
prop_query = prop_query.order_by(Property.cost.asc())
elif req_sort == 'newest':
prop_query = prop_query.order_by(Property.created_at.desc())
# 默认按热度降序
# 6. 执行分页
try:
page_obj = prop_query.paginate(page=req_page, per_page=constants.LIST_PER_PAGE)
total_pages = page_obj.pages
items = [item.to_search_dict() for item in page_obj.items]
except Exception:
return jsonify(code=RET.DBERR, msg='分页服务异常'), 500
# 组装返回数据结构
final_result = {'items': items, 'current': req_page, 'total': total_pages}
cached_res = json.dumps(final_result)
# 使用 Pipeline 批量写入 Redis
pipe = conn.pipeline()
pipe.hset(cache_hash_key, str(req_page), cached_res)
pipe.expire(cache_hash_key, constants.SEARCH_CACHE_TTL)
pipe.execute()
return jsonify(code=RET.SUCCESS, msg=ret_msg[RET.SUCCESS], data=cached_res)
前端分页与交互控制
搜索页的核心在于滚动监听与状态管理。利用全局变量记录当前页和加载状态,防止重复请求。
var SearchState = {
currPage: 1,
nextPage: 2,
totalPages: 1,
isLoading: false
};
function loadListings(mode) {
var filters = {
aid: $('.filter-active[data-type=area]').attr('data-id'),
sd: $('#start-picker').val(),
ed: $('#end-picker').val(),
sort: $('.filter-active[data-type=sort]').attr('data-key')
};
var pageNum = (mode === 'append') ? SearchState.nextPage : 1;
// 避免重复点击触发多次请求
if (SearchState.isLoading) return;
SearchState.isLoading = true;
var urlParams = new URLSearchParams({
aid: filters.aid,
sd: filters.sd,
ed: filters.ed,
sorted_by: filters.sort || 'latest',
page: pageNum
});
$.get('/api/v1.0/search/houses?' + urlParams.toString(), function(resp) {
SearchState.isLoading = false;
if (resp.code === 0 && resp.data) {
var parsedData = JSON.parse(resp.data);
SearchState.totalPages = parsedData.total;
var htmlContent = template('house-row', { houses: parsedData.items });
if (mode === 'refresh') {
$('#listing-container').html(htmlContent);
SearchState.currPage = 1;
SearchState.nextPage = 2;
} else {
$('#listing-container').append(htmlContent);
SearchState.currPage++;
SearchState.nextPage++;
}
}
}).fail(function() {
SearchState.isLoading = false;
});
}
// 窗口初始化
$(document).ready(function() {
// 解析 URL 参数恢复筛选状态
var params = decodeURI(window.location.search);
// ...省略参数回填 DOM 的代码...
// 首次加载
loadListings('refresh');
// 监听滚动实现懒加载
$(window).scroll(function() {
var scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
var clientHeight = document.documentElement.clientHeight || document.body.clientHeight;
var bodyHeight = document.documentElement.scrollHeight || document.body.scrollHeight;
// 距离底部不足 50px 且未加载完所有数据
if ((scrollTop + clientHeight) >= (bodyHeight - 50) && !SearchState.isLoading) {
if (SearchState.currPage < SearchState.totalPages) {
loadListings('append');
}
}
});
// 筛选控件改变后的处理
function applyFilter() {
loadListings('refresh');
hideDropdowns();
}
// 绑定点击事件到筛选项...
});