尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

城市房价数据分析系统:Flask+Vue前后端分离实战

城市房价数据分析系统:Flask+Vue前后端分离实战 简介城市房价数据分析系统基于Python/Flask MySQL Vue实现前后端分离架构面向需要完成毕业设计或工程实战的开发者覆盖数据爬取、数据存储、数据可视化、后台管理和房价预测完整链路旨在帮助用户洞察房价走向与趋势。数据可视化模块支持房价查询、房价对比、房价趋势三个业务后台管理包含用户登录与注册、权限管理、咨询管理、用户评论、用户通知五个功能并集成房价数据、新闻资讯、预测数据三类爬虫脚本。资源为ZIP压缩包共915个文件大小约24.48MB主要包含py、vue、js、css、json、sql等类型目录按back-end、front-end、爬虫、预测等模块划分结构清晰便于定位代码。已有2238人学习/下载。通过阅读源码和本地运行说明可快速启动前后端项目理解Flask接口设计与RESTful API、Vue组件化开发、MySQL表结构设计及爬虫落地实践为进一步改造或扩展功能提供扎实基础。1. 城市房价数据分析系统为什么选前后端分离拿到一份城市房价数据最迫切的需求往往不是画折线图而是搞明白三个问题哪个区均价最高、涨跌趋势在什么时间点转向、总价中位数和均值差多少才能反映市场真实水位。城市房价数据分析系统的核心就是把这三类问题变成可交互的查询界面而 Flask、MySQL、Vue 的组合正好覆盖数据采集入库、接口服务、前端可视化三个环节。前后端分离在这里不是跟风是因为房价分析天然需要多端复用同一套数据接口。后端只出 JSON前端既可以是管理系统也可以是数据大屏还可以是以后要做的移动端。用 Vue 管理组件状态和图表渲染Flask 专注于聚合查询和接口契约MySQL 存原始样本这个分工让每个层级的边界都清晰。这套方案适合两类人一类是用 Python 做数据分析想给分析结果套一个 Web 界面的工程师另一类是刚接触前后端分离需要一个真实业务场景把 Flask、Vue、MySQL 串起来练手的开发者。接下来按一条教科书之外的路线走一遍完整实现重点放在查询性能、数据口径和联调排错上。2. 系统架构与 MySQL 表结构设计2.1 分层架构与各层职责系统分为三个独立的部署单元Vue 前端应用、Flask 后端服务、MySQL 数据库。前端通过 HTTP 请求调用后端 REST API后端通过 SQLAlchemy 访问 MySQL三层之间不共享代码不共享会话。这种结构在开发环境可以拆成三个进程分别启动在生产环境则由 Nginx 统一接管静态资源和反向代理。各层的技术选型建议如下表这个组合在 2024 年仍然是最稳的搭配之一层级技术版本建议说明前端框架Vue3.4Composition API 配合script setup状态管理不需要引 Pinia 也能跑通构建工具Vite5.x比 webpack 冷启动快一个量级代理配置也简单图表库ECharts5.5折线图、柱状图、散点图都能覆盖地图需要额外的中国地图 GeoJSON后端框架Flask3.0轻量配合 Blueprint 组织路由不需要 Django 的重量级 ORMORMSQLAlchemy2.0Flask-SQLAlchemy 集成注意 2.0 版本 query 接口有变化数据库MySQL8.0必须用 8.0窗口函数和 CTE 是后面做同比环比的关键这套选型有一个值得注意的边界Flask 不适合做计算密集型的分析任务。如果数据量到了千万级且需要实时聚合应该把分析逻辑下沉到 MySQL 的物化视图或者引入 ClickHouse 这类列式存储。常规的城市房价数据单表百万行以内MySQL 配合合理索引完全扛得住。2.2 城区维度表与价格记录表设计房价分析系统里最容易踩的坑是表结构设计成单表大宽表所有字段堆一起数据量大了之后 GROUP BY 性能直线下降。常见做法是拆成分层维度表和事实表城区表存静态属性城市名、区名、经纬度价格记录表存每个月度均价和成交量。先看建表 SQL这是后续所有查询的地基CREATE DATABASE house_price DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE city_district ( id INT PRIMARY KEY AUTO_INCREMENT, city_name VARCHAR(50) NOT NULL COMMENT 城市名, district_name VARCHAR(50) NOT NULL COMMENT 城区名, lng DECIMAL(10, 6) COMMENT 经度, lat DECIMAL(10, 6) COMMENT 纬度, area_code VARCHAR(10) COMMENT 行政区划代码, UNIQUE KEY uk_city_district (city_name, district_name) ) ENGINE InnoDB COMMENT 城市城区维度表; CREATE TABLE district_price_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, district_id INT NOT NULL COMMENT 关联 city_district.id, stats_month CHAR(7) NOT NULL COMMENT 统计月份格式 2024-06, avg_price DECIMAL(12, 2) NOT NULL COMMENT 月度均价单位元/平, median_price DECIMAL(12, 2) COMMENT 挂牌价中位数, deal_count INT COMMENT 当月成交套数, total_amount DECIMAL(14, 2) COMMENT 当月总成交金额, source VARCHAR(30) DEFAULT manual COMMENT 数据来源, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_district_month (district_id, stats_month), KEY idx_stats_month (stats_month) ) ENGINE InnoDB COMMENT 城区月度价格记录表;提示stats_month使用CHAR(7)而不是DATE类型因为按月统计的数据不需要带日粒度。用字符串格式2024-06可以直接做字典序比较索引效率与DATE几乎无差异但数据导入时可以免去日期解析。两张表的核心设计逻辑是city_district表用联合唯一键保证一个城市下不会出现同名城区district_price_record表用district_id stats_month联合唯一键保证同一城区同一个月只有一条记录。这个设计撑起了后面所有聚合查询的正确性基础数据不会因为重复导入导致均价被重复计算。2.3 SQLAlchemy 模型与已有表对接表结构定好之后后端用 SQLAlchemy 模型映射。很多教程是从模型反向建表但实际工程里往往是 DBA 或数据分析师先定表后端只做映射这里用__table__方式对接已有表from flask_sqlalchemy import SQLAlchemy from sqlalchemy import Column, Integer, String, DECIMAL, TIMESTAMP, func db SQLAlchemy() class CityDistrict(db.Model): __tablename__ city_district id Column(Integer, primary_keyTrue) city_name Column(String(50)) district_name Column(String(50)) lng Column(DECIMAL(10, 6)) lat Column(DECIMAL(10, 6)) class DistrictPriceRecord(db.Model): __tablename__ district_price_record id Column(db.BigInteger, primary_keyTrue) district_id Column(Integer, nullableFalse) stats_month Column(String(7), nullableFalse) avg_price Column(DECIMAL(12, 2), nullableFalse) median_price Column(DECIMAL(12, 2)) deal_count Column(Integer) total_amount Column(DECIMAL(14, 2)) classmethod def latest_month(cls): return db.session.query(func.max(cls.stats_month)).scalar()模型里的latest_month类方法是一个非常实用的封装。前端需要查全市场最新均价时先取整张表的最大统计月份再用这个月过滤。如果不做这一步直接用GROUP BY取每个城区最新记录在 MySQL 里要写窗口函数逻辑复杂得多。这个写法在数据是月度批量导入的场景下尤其好使。3. Flask 后端 REST API 与查询参数设计3.1 Blueprint 组织路由与 CORS 配置Flask 项目不建议把所有路由写在一个文件里。房价分析系统的接口按业务域拆分成三个 Blueprintoverview处理总览统计trend处理趋势查询rank处理排行榜。这样每个模块的查询逻辑独立演进后期加接口不需要改动已有路由文件。项目结构可以这样组织house_price_backend/ ├── app.py # 应用入口注册蓝图 ├── config.py # 数据库连接配置 ├── models.py # SQLAlchemy 模型 ├── api/ │ ├── __init__.py │ ├── overview.py │ ├── trend.py │ └── rank.py └── utils/ └── response.py # 统一响应格式app.py中最关键的配置有两个数据库连接串和 CORS。数据库连接串直接决定了 PyMySQL 能否连上 MySQL 8.0CORS 配置则决定了 Vue 开发环境的请求能否被浏览器放行。from flask import Flask from flask_cors import CORS from api.overview import overview_bp from api.trend import trend_bp from api.rank import rank_bp def create_app(): app Flask(__name__) app.config[SQLALCHEMY_DATABASE_URI] ( mysqlpymysql://root:your_passwordlocalhost:3306/ house_price?charsetutf8mb4auth_pluginmysql_native_password ) app.config[SQLALCHEMY_TRACK_MODIFICATIONS] False app.config[JSON_AS_ASCII] False # 保证返回的中文不被转义 CORS(app, resources{r/api/*: {origins: *}}) db.init_app(app) app.register_blueprint(overview_bp, url_prefix/api/v1) app.register_blueprint(trend_bp, url_prefix/api/v1) app.register_blueprint(rank_bp, url_prefix/api/v1) return app注意连接串里的auth_pluginmysql_native_password不是可有可无的参数。MySQL 8.0 默认用caching_sha2_password认证老版本的 PyMySQL 会报Authentication plugin caching_sha2_password cannot be loaded错误。如果你用的是 PyMySQL 1.0 且 MySQL 8.0.34不写这个参数反而更安全但如果连接报错第一时间检查这里。JSON_AS_ASCII False这个配置容易漏掉。Flask 默认会把 JSON 里的中文转成\uXXXX的 ASCII 编码浏览器里看起来是一串转义符前端拿到后JSON.parse不会自动还原。设成False之后接口直接返回可读的中文字符。3.2 总览接口全国城市均价排行总览接口的职责是展示所有城市的最新一个月均价供前端首页的地图热力图或排行榜使用。这个查询虽然逻辑简单但参数设计上有讲究。from flask import Blueprint, request, jsonify from models import db, DistrictPriceRecord, CityDistrict overview_bp Blueprint(overview, __name__) overview_bp.route(/city/rank) def city_price_rank(): city request.args.get(city, typestr) limit request.args.get(limit, default20, typeint) limit min(limit, 50) # 防止请求方一次拉太多数据 # 先取最新统计月 latest_month DistrictPriceRecord.latest_month() if latest_month is None: return jsonify({code: 404, msg: 暂无数据, data: []}) query ( db.session.query( CityDistrict.city_name.label(city), func.round(func.avg(DistrictPriceRecord.avg_price), 2).label(avg_price), func.sum(DistrictPriceRecord.deal_count).label(total_deal) ) .join(CityDistrict, CityDistrict.id DistrictPriceRecord.district_id) .filter(DistrictPriceRecord.stats_month latest_month) .group_by(CityDistrict.city_name) .order_by(func.avg(DistrictPriceRecord.avg_price).desc()) .limit(limit) ) if city: query query.having(CityDistrict.city_name city) rows query.all() data [{city: r.city, avg_price: float(r.avg_price), total_deal: int(r.total_deal or 0)} for r in rows] return jsonify({code: 0, msg: success, data: data})这段代码里有两个容易被忽略的参数设计limit上限 50 是为了防止前端把全量城市列表一股脑渲染出来实际场景下排行榜展示 Top 20 足够city参数用having而不是where因为它在分组之后做过滤。如果用where会导致整个分组条件被破坏查询结果直接报错。func.round包在func.avg外层是因为 MySQL 的AVG返回的DECIMAL类型精度可能到小数点后 10 位JSON 序列化后前端显示一堆小数直接在前端做toFixed(2)也可以但后端做一次四舍五入能让所有调用方都拿到统一的格式这个口径一致性的问题越早统一越好。3.3 趋势接口城区月度均价折线图数据趋势接口是前端折线图的数据来源前面的设计全部服务于这个查询。它接收城市、城区、时间范围三个参数返回按月排列的均价序列。这里遇到的问题是数据库里可能有缺失月份前端直接拿返回的月份列表做 X 轴可能出现不连续的时间刻度。trend_bp.route(/district/trend) def district_trend(): city request.args.get(city, requiredTrue, typestr) district request.args.get(district, typestr, default) months request.args.get(months, default12, typeint) months min(max(months, 3), 36) # 限制取值范围 if months 24: months 24 # 至少查两年确保同比有参考基准 latest DistrictPriceRecord.latest_month() start_month f{int(latest[:4]) - 1}{latest[5:7]} if months 24 else latest # 更精确的计算方式 from datetime import datetime from dateutil.relativedelta import relativedelta start_date datetime.strptime(latest, %Y-%m) - relativedelta(monthsmonths - 1) start_month start_date.strftime(%Y-%m) query ( db.session.query( CityDistrict.city_name, CityDistrict.district_name, DistrictPriceRecord.stats_month, DistrictPriceRecord.avg_price, DistrictPriceRecord.median_price ) .join(CityDistrict, CityDistrict.id DistrictPriceRecord.district_id) .filter(CityDistrict.city_name city, DistrictPriceRecord.stats_month start_month, DistrictPriceRecord.stats_month latest) .order_by(DistrictPriceRecord.stats_month.asc()) ) if district: query query.filter(CityDistrict.district_name district) rows query.all() result {} for r in rows: key r.district_name if district else r.city_name result.setdefault(key, {xaxis: [], avg_price: [], median_price: []}) result[key][xaxis].append(r.stats_month) result[key][avg_price].append(float(r.avg_price)) result[key][median_price].append(float(r.median_price)) return jsonify({code: 0, data: result})months参数的校验逻辑值得细看先用min(max(months, 3), 36)把用户输入限制在 3 到 36 之间然后判断如果小于 24 就强制拉到 24 个月。这么做的原因很简单房价趋势如果只看三个月毫无参考意义至少要看两年才能覆盖去年同期。前端默认传 12 的情况下会被强制拉到 24这是后端定义最小分析窗口的做法。这个接口的返回格式是嵌套字典而不是列表前端拿到result[北京]就能直接填图。如果返回列表还需要前端做一层find操作。接口设计往「前端拿数据即用」的方向靠联调阶段的沟通成本会低很多。4. Vue 前端工程化与 ECharts 可视化展示4.1 Vite 初始化与 axios 请求封装前端部分用 Vite 创建 Vue 3 项目这是目前 Vue 生态的标配脚手架。npm create vuelatest创建之后需要装四个依赖axios请求库、echarts图表库、vue-router路由、element-plusUI 组件库表格和表单直接用现成的。# 创建项目选择 Vue Router 和 ESLint npm create vuelatest house-price-frontend cd house-price-frontend npm install axios echarts element-plus vue-router npm install -D sass # 样式预处理非必需axios 封装是所有前后端分离项目的第一步核心是集中管理 baseURL、超时时间、请求拦截器和响应拦截器。房价分析系统没有用户登录但预留了 token 处理逻辑方便以后接入权限体系。// src/api/request.js import axios from axios const request axios.create({ baseURL: /api/v1, timeout: 15000 }) // 请求拦截器自动携带 token request.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }, error Promise.reject(error) ) // 响应拦截器统一处理业务码和错误提示 request.interceptors.response.use( response { const res response.data // 后端统一返回 { code, msg, data } 结构 if (res.code ! 0) { // 业务异常统一弹出 msg return Promise.reject(new Error(res.msg || 请求失败)) } return res.data // 直接返回 data 字段业务层拿到的就是干净数据 }, error { if (error.response error.response.status 401) { // token 失效处理 localStorage.removeItem(token) window.location.href /login } return Promise.reject(error) } ) export default request提示baseURL设为/api/v1开发环境依赖 Vite 的 proxy 把请求转发到 Flask 的 5000 端口生产环境则交给 Nginx 反向代理。这样浏览器里始终走相对路径不会出现跨域问题。baseURL用/api/v1而不是http://localhost:5000/api/v1是关键决策。如果写成绝对地址开发环境必须依赖CORS(app, resources{...})的跨域配置才能请求通生产环境还得改代码。用相对路径加上代理配置环境之间切换零改动。4.2 Vite 代理配置与开发调试开发环境跨域问题的标准解法是在vite.config.js里配置代理把/api前缀的请求转发到 Flask 服务// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:5000, // Flask 开发服务器地址 changeOrigin: true, // 不要重写路径后端 Blueprint 注册了 /api/v1 前缀 } } } })这个配置里最容易出错的是rewrite路径。如果写成rewrite: (path) path.replace(/^\/api/, )请求打到后端就变成/v1/...和 Blueprint 注册的/api/v1前缀对不上直接 404。除非你的后端注册时用的是/v1否则代理禁止重写路径。调试阶段的建议是前后端分开看日志。Vite 终端显示的是静态资源编译和代理转发状态Flask 终端显示的是 SQLAlchemy 的 SQL 执行日志和 HTTP 状态码。两边对照能快速定位问题是出在前端请求没发出、代理转发失败还是后端 SQL 报错。4.3 ECharts 折线图组件与 props 驱动更新图表是房价分析系统的门面。ECharts 在 Vue 组件里的正确用法不是直接写 options 然后用v-html塞进去而是封装成独立的图表组件用props控制数据输入用watch监听数据变化自动更新图表。!-- src/components/PriceTrendChart.vue -- template div refchartRef classchart-container/div /template script setup import { ref, onMounted, watch, onBeforeUnmount } from vue import * as echarts from echarts const props defineProps({ xAxis: { type: Array, required: true }, seriesData: { type: Array, required: true }, title: { type: String, default: 城市均价走势 } }) const chartRef ref(null) let chartInstance null const renderChart () { if (!chartInstance) return chartInstance.setOption({ title: { text: props.title, left: center, textStyle: { fontSize: 16 } }, tooltip: { trigger: axis, valueFormatter: (value) value.toFixed(2) }, grid: { left: 3%, right: 4%, bottom: 3%, containLabel: true }, xAxis: { type: category, data: props.xAxis }, yAxis: { type: value, name: 元/平方米, scale: true, // 坐标轴不从 0 开始放大波动 axisLabel: { formatter: (v) (v / 10000).toFixed(1) 万 } }, series: [{ data: props.seriesData, type: line, smooth: true, areaStyle: { opacity: 0.15 }, markLine: { data: [{ type: average, name: 均值线 }], lineStyle: { type: dashed, color: #999 } } }], dataZoom: [{ type: inside, start: 0, end: 100 }] }) } onMounted(() { chartInstance echarts.init(chartRef.value) renderChart() }) watch(() [props.xAxis, props.seriesData], () renderChart(), { deep: true }) onBeforeUnmount(() { if (chartInstance) { chartInstance.dispose() chartInstance null } }) /script style scoped .chart-container { width: 100%; height: 400px; } /style这个组件的几个细节都是实际开发中容易踩的坑yAxis.scale: true让 Y 轴不从 0 开始房价几万元级别的数据放在 0 到 5 万的区间比如 6 万到 7 万的波动会被压成一条几乎水平的线。开启动态缩放后趋势线才有辨识度。valueFormatter是 ECharts 5.3 才有的 tooltip 配置之前版本要在tooltip.formatter里写回调函数。用valueFormatter更简洁但注意版本兼容。onBeforeUnmount里手动dispose()释放实例。组件被路由切换销毁后如果不销毁图表实例浏览器内存里会残留处于活动状态的 canvas 对象多切换几次页面就卡顿。4.4 排行榜页面与路由参数联动房价分析系统的典型交互是用户在排行榜点击一个城区下面的折线图切换到该城区的历史趋势。这个联动不需要组件间通信用vue-router的 query 参数就够了。// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: overview, component: () import(/views/OverviewView.vue) }, { path: /trend/:city, name: trend, component: () import(/views/TrendView.vue) } ] const router createRouter({ history: createWebHistory(), routes }) export default router// 在排行榜中点击切换到趋势页 const goTrend (city) { router.push({ name: trend, params: { city }, query: { months: 24 } }) }表格使用 Element Plus 的el-table渲染后端返回的排名数据。列的格式化用 formatter 函数把后端返回的数字转成“万元/平方米”或“套”后端只传原始数值显示格式由前端控制这样同一个接口既能给表格用也能给图表用。5. 房价分析指标设计环比、同比与城市均价计算5.1 分析指标口径与 SQL 实现房价分析系统的价值不在于展示原始 price_record 表而在于把原始数据加工成决策可读的指标。需要重点实现的指标有三个月度均价、环比涨跌幅、同比涨跌幅。月度均价是基础指标计算公式是总成交金额 / 总成交面积在表结构里如果只有avg_price而没有total_area就用AVG(avg_price)近似代替。这两个口径在样本量足够大且每套房源面积差异不大时接近但如果总价高的房子面积偏大算数平均值会低估市场水平。环比和同比用 MySQL 8.0 的窗口函数LAG实现最优雅SELECT city_name, district_name, stats_month, avg_price, LAG(avg_price, 1) OVER ( PARTITION BY district_id ORDER BY stats_month ) AS prev_month_price, ROUND( (avg_price - LAG(avg_price, 1) OVER ( PARTITION BY district_id ORDER BY stats_month )) / LAG(avg_price, 1) OVER ( PARTITION BY district_id ORDER BY stats_month ) * 100, 2 ) AS mom_change_pct FROM district_price_record JOIN city_district ON district_price_record.district_id city_district.id WHERE stats_month 2024-01 ORDER BY city_name, district_name, stats_month;这段 SQL 用LAG(avg_price, 1)取当前行之前一行的均价PARTITION BY district_id保证窗口只在同一个城区内滑动不会跨区错位计算。三个LAG重复写了三遍MySQL 不支持在同一个 SELECT 里对窗口函数定义别名然后复用要复用就得包一层子查询或 CTE。如果你的 MySQL 还是 5.7没有窗口函数用自连接替代也能算环比但 SQL 会长三倍且性能下降明显。这也是选型时强调 MySQL 8.0 的核心原因——房价分析这类时间序列的同比环比场景就是冲着窗口函数去的。5.2 中位数计算与 PERCENTILE_CONT 性能权衡均价反映市场平均水平但容易受极端高总价或低总价房源影响。一套 5000 万的豪宅就能把一个区的均价拉高 5%。中位数是更稳健的指标城市房价分析系统里建议至少同时展示均值和分位数。MySQL 8.0 没有内置MEDIAN()聚合函数要实现分位数有两条路一条是用PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY price)窗口函数另一条是ROW_NUMBER()定位中间行。SELECT city_name, ROUND(AVG(avg_price), 2) AS mean_price, ROUND( MAX(CASE WHEN rn cnt / 2 THEN avg_price END), 2 ) AS median_price FROM ( SELECT city_name, avg_price, ROW_NUMBER() OVER ( PARTITION BY city_name ORDER BY avg_price ) AS rn, COUNT(*) OVER ( PARTITION BY city_name ) AS cnt FROM district_price_record JOIN city_district ON district_price_record.district_id city_district.id WHERE stats_month 2024-06 ) t GROUP BY city_name;这个写法有几个要点子查询里用ROW_NUMBER()给每个城市内部的均价排序COUNT(*) OVER()统计每个城市的样本数。外层用MAX(CASE WHEN rn cnt / 2 THEN avg_price END)取中位数。因为每个城市只有一个行满足rn cnt / 2MAX在这里就是取那个值。当样本数是偶数时严格的中位数应该是中间两个数的平均值这个写法只取了靠后的那个。数据量在百级以下时误差可接受上了千必须做(rn FLOOR(cnt/2) OR rn CEIL(cnt/2))取两行求均值。这个查询的代价是子查询里对全量月度记录做窗口排序数据量到十万级之后响应时间会明显上升。常见做法是把分析结果做成汇总表每天凌晨跑批写入district_price_stat表前端只查汇总表。这套系统的分析链路按“原始表 → 指标计算 → 汇总表”走分析查询和业务查询隔离。6. 部署联调与性能优化实战技巧6.1 三个高频排错点端口、字符集与 token前后端分离项目第一次联调大概率会遇到三个问题每个都有明确的排查路径。第一个是跨域请求失败。现象是浏览器控制台报Access to XMLHttpRequest at http://localhost:5000/api/v1/... has been blocked by CORS policy。如果开发环境已经配了 Vite proxy这个报错说明请求发到了 5000 端口而不是通过代理转发。检查前端baseURL是否用了完整 IP 加端口改成/api/v1重新加载。第二个是 MySQL 中文乱码。后端返回 JSON 正常但数据库里存进去的中文城区名变成???。根因是建库时没有指定utf8mb4在 2.2 节的建表 SQL 里已经注释了DEFAULT CHARACTER SET utf8mb4。如果已经建错了库执行ALTER DATABASE house_price CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;然后对每张表执行对应的ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4;。第三个是前端请求 401。如果后端的接口加了 JWT 认证Vue 端请求拦截器没有从 localStorage 取到 token所有请求都会被拒绝。检查 localStorage 里有没有 token没有就重新登录。这套系统如果只做数据分析展示建议初期不要加认证把全部精力放在数据查询和图表展示上认证后置。6.2 响应数据缓存给聚合接口加 ETag前端频繁切换城市和月份后端每次都执行 GROUP BY 聚合数据库压力是实打实的。房价数据一个月才更新一次同一查询在两天内返回结果完全一样完全可以用 ETag 做 HTTP 缓存。Flask 里实现 ETag 不需要第三方库利用make_response手动设置头即可from flask import make_response import hashlib app.after_request def add_etag(response): if response.status_code 200 and response.mimetype application/json: data response.get_data() etag hashlib.md5(data).hexdigest() response.set_etag(etag) # 允许前端在本地缓存 10 分钟 response.cache_control.max_age 600 response.cache_control.public True return response通过after_request钩子统一给所有 JSON 响应设置 ETag。前端 axios 默认会缓存GET请求的 ETag下次请求同一个 URL 时自动带上If-None-Match头后端比较一致就返回 304 Not ModifiedF12 Network 面板里能看到多次请求只有第一次是 200后续全部是 304或命中最激进的前端内存访问策略数据库查询根本没执行。需要注意的一点是cache_control.max_age 600如果数据在 10 分钟内更新了前端可能拿到旧数据。房价月度数据没有这个问题但如果是实时性要求高的系统这个缓存时间要调小或者去掉。6.3 Flask 生产部署与 Vue 静态资源集成生产环境最稳的部署方案是 Nginx 托管 Vue 构建产物同时反向代理/api到 Gunicorn 启动的 Flask 进程。整个链路是浏览器 → Nginx静态页面→ Nginx转发 /api→ Gunicorn → Flask → MySQL。Gunicorn 启动参数建议设置成gunicorn -w 4 -b 127.0.0.1:8000 app:app4 个 worker 对数据展示型系统足够再多反而因为 GIL 和数据库连接数导致性能下降。Nginx 的配置里关键是把/api/的请求proxy_pass到 Gunicornserver { listen 80; server_name your-domain.com; # Vue 静态资源 root /var/www/house-price-frontend/dist; index index.html; # 前端路由 history 模式支持 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }前后端分离部署到这一步才算完整落地。Vue 项目执行npm run build产出dist/目录上传后 Nginx 直接指向这个目录history模式下的路由刷新页面由try_files兜底转发到index.html不会 404。接口层通过反向代理隐藏了 Gunicorn 的真实端口对外只需暴露 80。上线后用curl -I http://your-domain.com/api/v1/city/rank验证响应头里的Content-Type和ETag确认链路通畅即可。本文还有配套的精品资源点击获取
返回列表