1. 项目定位与整体设计思路
先说一下这个项目到底做了什么。它是一个基于 Django 框架的旅游景点数据分析与可视化系统,核心工作是把某 OTA 平台上大量景点的公开信息抓下来,经过清洗、统计分析,再用图表的形式展示出来。这套东西做成毕设非常合适,因为它把爬虫、数据清洗、数据库设计、后端开发、前端可视化全都串在了一条线上,每一环都是企业里真正会用到的东西,不像有些毕设只做增删改查,答辩时一问技术点就露怯。
这个系统能解决的问题,说白了就两件事:一是让海量非结构化的景点数据变得可读,二是让不懂技术的人也能一眼看出“哪个区域景点热度高”“什么类型的景点评分最稳定”“游客评论里提到最多的关键词是什么”。如果你正在准备毕设、课程设计,或者想自己搭一个数据展示类的小项目用来练手,这套方案可以完整复现,而且代码量不大,思路清晰,答辩时能讲的东西很多。
整体架构上我选了 Django 而不是 Flask,原因有两条。第一,Django 自带 Admin 后台、ORM、认证系统,这些在毕设里能省下大量造轮子的时间,尤其是你要给评委演示“用户登录、收藏景点、后台管理”这一套流程时,Django 的开箱即用优势非常明显。第二,Django 的 MTV 架构和项目的模块化组织方式,和你在论文里写“分层设计”“松耦合”这些概念天然对应,写作时不用硬拼凑。数据存储我用的是 MySQL,版本 8.0,如果你本机不想装数据库,改成 SQLite 也完全可以,Django 的 ORM 在这两种数据库之间切换只需要改一个配置项,代码基本不用动。
项目分成三个模块:数据采集模块、数据分析模块、可视化展示模块。数据采集模块负责抓取景点的基础信息和用户评论,数据清洗后写入 MySQL;数据分析模块用 pandas 做统计、关联分析和文本挖掘;可视化模块通过 ECharts 在前端展示结果。整体流程可以概括为“采集-清洗-入库-分析-展示”五步,这也是论文里最容易画成架构图的一条主线。
需要提醒的一点是,毕设和商业项目不一样,不需要你把系统做得多么高并发、多么分布式,但一定要把每一个环节的原理讲透。比如你用 pandas 做数据清洗,就要能说清楚为什么要去重、为什么要处理缺失值、异常值判断的标准是什么。这些细节才是答辩时老师真正感兴趣的地方,也是本文后面我会重点展开的内容。
2. 数据准备:爬虫设计与清洗降噪
2.1 爬虫目标与字段设计
我先明确爬虫要抓什么。旅游景点信息通常包括:景点名称、所在城市、景点等级(比如 AAAA、AAAAA)、门票价格、评分、评论数、景点简介、用户评论内容、经纬度坐标。其中评分和评论数是后续做热度分析的核心指标,经纬度是画地图散点图的必备字段,用户评论则用于词云和情感倾向分析。
我写爬虫用的工具是 requests + BeautifulSoup,没有用 Scrapy。原因很简单,数据量在几千条级别,Scrapy 的异步并发优势发挥不出来,反而要花时间维护爬虫框架的配置。反观 requests 配合 time.sleep() 限速,代码结构一目了然,出现问题时也容易排查。数据源方面,我用的是某旅游平台的景点列表页和详情页,列表页拿基础信息,详情页拿评论。这里我不写具体的平台名,你只需要知道它的页面结构是“列表页 + 详情页”这种经典模式,换任何一个类似站点思路都一样。
字段设计上,我在 Django 模型里定义了景点表(Spot)和评论表(Comment)两张核心表。景点表字段包括 name、city、level、price、score、comment_count、intro、longitude、latitude;评论表包括 spot(外键)、user_name、content、rating、date。这样的设计可以支持“某个景点的平均评分”“某个城市景点数量分布”这类查询,既简单又满足需求。
2.2 反爬应对与请求限速
写爬虫最常遇到的就是被封 IP。我的经验是,控制请求频率比伪造任何 Headers 都有效。我在代码里把两次请求的间隔设置在 2 到 4 秒之间随机浮动,这样既不会让目标服务器压力太大,也不会因为请求太慢导致整个采集过程冗长。实测抓取约 2000 个景点的数据,耗时大约 40 分钟,这个速度在毕设场景下完全可以接受。
import time import random import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } def fetch_page(url): try: resp = requests.get(url, headers=headers, timeout=10) resp.raise_for_status() resp.encoding = "utf-8" return BeautifulSoup(resp.text, "html.parser") except Exception as e: print(f"[请求失败] {url} -> {e}") return None finally: time.sleep(random.uniform(2, 4))上面这段代码里,resp.encoding = "utf-8" 这一行很关键。有相当一部分页面虽然声明了 UTF-8,但实际的 Content-Type 里给的是 GBK,如果不显式指定编码,抓回来的中文就是乱码。我最初没写这行,导致景点简介里全是“锟斤拷”,清洗时才发现,返工成本很高。
遇到请求失败的情况,我没有做复杂的重试机制,而是直接记录日志跳过。因为在毕设数据量需求下,漏掉几十条数据不影响整体分析结论,反而如果死磕重试,浪费时间不说,还容易触发更严格的反爬策略。这个取舍心里要有数。
2.3 数据清洗的五个步骤
爬下来的数据直接入库是不行的,脏数据会直接影响后续统计结果。我的清洗过程分五步:去重、缺失值处理、异常值过滤、格式归一化、文本清洗。
去重是最容易忽略但又最重要的一步。同一个景点可能在列表页出现两次,也可能在详情页里有重复的评论。我对景点表用 name + city 作为联合去重条件,评论表则用 spot + user_name + date 作为去重依据。pandas 里直接用 drop_duplicates 就能完成,但要注意先指定 subset,不然它会按整行判断,达不到预期效果。
缺失值处理上,我采用“分类处理”的策略,不能一刀切。比如门票价格缺失,我会看同一城市同等级景点的平均票价来填充;经纬度缺失,我根据景点名称调用免费的地理编码接口反查;而用户评论内容缺失,就直接删除该条评论,因为做文本分析时空内容没有意义。这里我用的原则是:对后续分析有用的字段才值得花力气补全,无关紧要的字段直接标记为“未知”即可。
异常值过滤是针对 price 和 score 这样的数值字段。比如门票价格出现负数或者 99999 这种明显错误的值,评分出现 0 分或者超过 5 分的情况,都要单独处理。我的做法是设定合理的取值范围,把不在范围内的记录单独导出到一个 excel 文件里人工核验,而不是直接删除,这样论文里还能多写一段“数据审计”的说明,显得更严谨。
格式归一化包括把日期统一成 YYYY-MM-DD,把“AAAAA”统一成“5A”,把经纬度统一成十进制度的格式。这一步虽然琐碎,但对于后续的地图可视化至关重要。很多地理库只认十进制度,不认度分秒格式,你在写入数据库之前转换好,后面就少踩一个坑。
文本清洗专指评论内容。我的处理方式是先去除 HTML 标签和特殊符号,再过滤掉“很好”“不错”这类信息量极低的短评,最后用 jieba 分词做词频统计。这几步对词云的呈现效果影响很大,如果不清洗,词云里会出现大量“就是”“我们”,完全看不出景点特色。
2.4 清洗代码的核心片段
下面这段代码是我清洗流程里最有代表性的部分,集中体现了去重、缺失值填充和异常值过滤的思路:
import pandas as pd df = pd.read_csv("spider_raw.csv", encoding="utf-8") # 1. 去重 df = df.drop_duplicates(subset=["name", "city"]) # 2. 缺失值处理 df["price"].fillna(df.groupby("city")["price"].transform("median"), inplace=True) df["score"].fillna(df["score"].median(), inplace=True) # 3. 异常值过滤 df = df[(df["price"] >= 0) & (df["price"] <= 1000)] df = df[(df["score"] >= 1) & (df["score"] <= 5)] # 4. 格式归一化 df["date"] = pd.to_datetime(df["date"]).dt.strftime("%Y-%m-%d") # 5. 保存清洗结果 df.to_csv("spot_cleaned.csv", index=False, encoding="utf-8-sig")这里我用了 utf-8-sig 编码保存,这是给 Excel 用户准备的。如果你直接用 utf-8 保存,Excel 打开时会乱码,而 utf-8-sig 会在文件头加入 BOM,Excel 就能正确识别。这个细节在答辩演示数据的时候特别有用,我第一次展示时就被导师提醒了。
3. Django 后端与数据库建模
3.1 MTV 架构在项目中的落地
Django 的 MTV 模式在这个项目里体现得非常清晰。Model 层定义了数据结构,Template 层负责 HTML 渲染,View 层实现业务逻辑。不过在实际开发中,我没有完全依赖 Django 的 Template 语法去渲染图表页面,而是采用了前后端分离的思路:业务页面用 Django Template 渲染骨架,图表数据通过 JSON 接口返回,前端用 ECharts 异步加载。
这样做的原因是,图表交互是纯前端的事,如果强行用 Django Template 拼接数据到 JS 变量里,代码既混乱又难以维护。改成 JSON 接口后,前端逻辑和后端逻辑完全解耦,我甚至可以用同一套后端接口同时服务 PC 端和移动端页面,这在论文里也是加分项。
Django 的项目结构我是这样组织的:一个 project 叫 TravelAnalysis,下面有四个 app,分别是 spots(景点信息)、comments(评论数据)、analysis(统计接口)、users(登录注册收藏)。每个 app 各司其职,迁移、测试、路由都互不干扰。这种划分方式不是拍脑袋定的,它对应着我数据库里的四张核心表,也方便在论文里画模块图。
3.2 模型设计与 ORM 查询
数据库建模是后端开发的核心。我设计的 Spot 模型如下:
from django.db import models class Spot(models.Model): name = models.CharField(max_length=100, verbose_name="景点名称") city = models.CharField(max_length=50, verbose_name="所在城市") level = models.CharField(max_length=20, blank=True, verbose_name="景点等级") price = models.DecimalField(max_digits=8, decimal_places=2, default=0, verbose_name="门票价格") score = models.DecimalField(max_digits=3, decimal_places=2, default=0, verbose_name="评分") comment_count = models.IntegerField(default=0, verbose_name="评论数量") intro = models.TextField(blank=True, verbose_name="景点简介") longitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, verbose_name="经度") latitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, verbose_name="纬度") created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间") class Meta: db_table = "t_spot" verbose_name = "景点信息" def __str__(self): return self.name这里有几个设计细节值得注意。第一,price 不用 FloatField 而用 DecimalField,是因为浮点数在 MySQL 里存储可能会产生精度误差,DecimalField 能保证货币字段精确到分。第二,经纬度字段设置 decimal_places=6,这个精度大约对应 0.1 米的误差,画地图完全够用,并且不会占用过多的存储空间。如果你用 FloatField 存经纬度,一些地图库在解析时可能出现浮点精度问题,需要额外处理。
ORM 查询方面,我写过最复杂的一条聚合查询是按城市统计景点数量和平均评分:
from django.db.models import Count, Avg from .models import Spot city_stats = Spot.objects.values("city").annotate( total=Count("id"), avg_score=Avg("score") ).order_by("-total")[:20]values + annotate 的组合在 Django 里极其常用,它对应 SQL 里的 GROUP BY,自己写原生 SQL 反而容易出错。因为我一开始用原生 SQL 写这个统计,结果发现不同数据库对 GROUP BY 的语法要求不一致,后来统一改成 ORM 之后,代码简洁了很多,迁移数据库也不用改逻辑。
3.3 评论数据的文本管理
评论表是连接爬虫和分析模块的桥梁。它的模型设计相对简单:
class Comment(models.Model): spot = models.ForeignKey(Spot, on_delete=models.CASCADE, related_name="comments") user_name = models.CharField(max_length=50, verbose_name="评论者") content = models.TextField(verbose_name="评论内容") rating = models.DecimalField(max_digits=2, decimal_places=1, verbose_name="评分") date = models.DateField(null=True, blank=True, verbose_name="评论日期")外键关联到 Spot 之后,我可以方便地通过 spot.comments.all() 获取某个景点的所有评论,也可以链式调用 filter 做条件筛选。on_delete=models.CASCADE 表示景点删除时评论也删除,这在毕设系统里是对的,但从数据资产保护的角度看,如果你的系统要长期运营,更合理的方式是 on_delete=models.SET_NULL 保留历史评论。这里可以当做一个扩展点写进论文里。
文本管理不只是存储,还包括分析前的分词。我在项目里写了一个独立脚本,从 MySQL 中提取全部评论,用 jieba.cut 进行分词,再去除停用词,最后统计每个词的出现频率。这里我补充一个细节:停用词表不能只用网上现成的那份,我根据旅游场景自定义了一个停用词列表,把“景区”“门票”“景点”“值得”这类高频但没有区分度的词也加进去了。如果不加,词云图上最大的两个字可能是“景区”,观感很受影响。
3.4 数据库配置与迁移注意事项
Django 连接 MySQL 需要配置 DATABASES,同时安装对应的数据库驱动。我用的是 mysqlclient,配置如下:
DATABASES = { "default": { "ENGINE": "django.db.backends.mysql", "NAME": "travel_db", "USER": "root", "PASSWORD": "your_password", "HOST": "127.0.0.1", "PORT": "3306", "OPTIONS": {"charset": "utf8mb4"}, } }重点说三个容易踩的坑。第一个是字符集必须指定 utf8mb4 而不是 utf8,因为 utf8 在 MySQL 里存不下 4 字节的 emoji 字符,游客评论里经常有笑脸符号,如果字符集不对,执行迁移时可能报错或写入失败。第二个是时区问题,Django 默认 USE_TZ=True,写入 MySQL 的日期时间是带时区信息的,但你的爬虫抓取的是本地时间,如果不做统一转换,时间排序会出错。我的处理是在爬虫脚本入库前把时间统一转成 Asia/Shanghai 时区,这样在页面上展示不会出现 8 小时的偏差。第三个是迁移报错时不要盲目删表重来,先执行 makemigrations 看是否有模型字段名写错,绝大部分问题都是模型字段和数据库列名不一致导致的。
4. 分析算法与可视化呈现
4.1 热度分析与统计指标体系
数据分析脱离了业务指标就成了纯粹的代码堆砌,所以我在系统里设计了一套“热度分析”指标。热度不能只看评论数,因为门票价格过高会抑制评论量,而免费公园的评论数天然就高。我用了三个维度加权合成热度指数:评论数、平均评分、门票价格指数。热度分数的计算公式是:
热度分 = 评论数的自然对数 * 0.5 + 平均评分 * 0.3 + 免费指数 * 0.2
其中免费指数 = 1 表示免费,0 表示收费。为什么用自然对数而不是直接用评论数?因为评论数分布是长尾的,热门景点有几千条评论,冷门景点只有几条,直接线性加权会导致热门景点碾压其他所有因素。取对数之后再参与计算,量级就相对均衡了。
我把所有景点的热度分归一化到 0 到 100 之间,然后按区间划分为“高热”“中热”“低热”三档。这个划分不是简单均分,而是参照数据分布:前 20% 为高热,后 30% 为低热,中间为普通。这样做的好处是地图上的热力层次感更强,不会出现大部分点都是红色或者大部分点都是绿色的情况。
4.2 关联分析与游客偏好挖掘
这一块是整篇论文里最能体现“数据分析”价值的部分。我用了经典关联规则挖掘里的 Apriori 算法,分析的是“游客去了哪些景点组合”。这里的数据来源不再是景点的静态属性,而是网络上的游记行程数据。我把每篇游记中连续提到的景点视为一个事务,然后挖掘频繁项集。
举个实际产出:在北方某沿海城市的游记数据里,“海边栈道—海鲜市场—城市博物馆”这个组合的支持度明显高于其他组合。这个结果表面上很平常,但如果能结合地理位置去解读,就会变成有价值的信息:这三个地点恰好分布在一条由西向东的线路上,反映了游客的动线习惯。
Apriori 算法在 Python 里的实现可以直接用 mlxtend 库,不需要自己造轮子:
from mlxtend.frequent_patterns import apriori, association_rules import pandas as pd # df_encoded 是已经商品编码化的矩阵,行是游记,列是景点 frequent_itemsets = apriori(df_encoded, min_support=0.005, use_colnames=True) rules = association_rules(frequent_itemsets, metric="lift", min_threshold=1.2) rules = rules.sort_values("lift", ascending=False)min_support 这个参数需要仔细调试,设得太小会导致挖掘出的规则数量巨大,大部分都是偶然共生,没有实际意义;设得太大又什么都挖不出来。我试了 0.01、0.005、0.003 几个档位,最终在 0.005 时得到了数量适中的规则集。答辩时如果老师问到参数为什么取这个值,你就回答:因为在这个阈值下规则的平均置信度稳定在 0.6 以上,且规则数不超过 50 条,兼顾了可信度和可解释性。这个回答体现了你是真的调过参数,而不是照抄别人的代码。
4.3 ECharts 可视化的选型与实现
前端可视化我选了 ECharts,没有选 Highcharts 或 D3.js。Highcharts 商业使用需要授权,D3 的学习曲线太陡,不适合毕设周期。ECharts 的文档完善、图表类型多、社区案例丰富,加上它默认的视觉配色本来就好看,对非设计出身的同学很友好。
我的可视化页面包含四个核心图表,分别对应不同的分析角度:
- 中国地图散点图:根据景点经纬度绘制,点的大小表示评论数,颜色表示热度分
- 城市 TOP10 柱状图:展示景点数量最多的十个城市
- 景点类型饼图(或环形图):展示自然风光、历史古迹、主题公园等类型占比
- 评论词云:直观展示高频词
地图散点图在 ECharts 里需要先注册中国地图的 GeoJSON 数据,我用的是从公开地图数据仓库下载的省级行政区 GeoJSON 文件,体积大约 2MB。注册方式在 ECharts 5 里是 echarts.registerMap("china", chinaJson),然后 series 的 type 设为 "effectScatter" 或者 "scatter",配上坐标系的 geo 属性。
这里有一个实际体验上的坑:如果一次性把几千个景点都绘制到地图上,页面会明显卡顿,浏览器的性能扛不住。我的解决办法是只展示热度排名前 500 的景点,并根据热力等级做分层显示,用户点击图例可以切换。这既保证了体验,也让页面有了交互感,不再是一张静态图。
4.4 Django 接口如何给前端喂数据
前端图表不能直接读取数据库,要经过 Django 的 JSON 接口中转。我在 analysis app 里实现了一个统计接口,以下是城市统计接口的完整代码:
from django.http import JsonResponse from django.views.decorators.http import require_GET from spots.models import Spot from django.db.models import Count @require_GET def city_stats_api(request): stats = list( Spot.objects.values("city") .annotate(total=Count("id")) .order_by("-total")[:10] ) return JsonResponse({"data": stats}, json_dumps_params={"ensure_ascii": False})JsonResponse 返回中文时,默认会把非 ASCII 字符转成转义序列,前端拿到之后要 unicode 解码才能用。我在 json_dumps_params 里加了 ensure_ascii: False,直接返回原始中文字符串,省掉了前端的解码逻辑。这是开发时很容易忽略但影响很大的细节。
前端通过 fetch 拉取这个接口,然后设置 ECharts 的 option:
fetch('/api/city_stats/') .then(res => res.json()) .then(data => { const cities = data.data.map(item => item.city); const counts = data.data.map(item => item.total); myChart.setOption({ xAxis: { type: 'category', data: cities }, yAxis: { type: 'value' }, series: [{ type: 'bar', data: counts }] }); });整套流程走下来,从数据库到前端图表的链路就通了。后续想加图表,只需要在后端加一个统计接口,在前端复制一段类似的 fetch 代码即可,扩展性很好。
5. 核心页面与功能模块拆解
5.1 数据概览看板
数据概览看板是整个系统的门面,也是答辩时最先展示的页面。我的设计是顶部放四个指标卡,分别是景点总数、评论总数、平均评分、平均门票价格;页面中部放地图散点图和城市 TOP10 柱状图;底部放热度区间分布饼图。
指标卡的数字我是这样算的:用 ORM 的 aggregate 一次查出所有汇总值,而不是前端展示时再逐个计算。
from django.db.models import Count, Avg summary = Spot.objects.aggregate( total=Count("id"), avg_score=Avg("score"), avg_price=Avg("price") )一次数据库查询搞定多个统计指标,性能上比循环单个查询好很多。在数据量大到万级时,这个差异尤其明显。答辩时可以顺便提一下这个优化思路,展示你对查询性能的敏感度。
地图的部分我做了三层交互:第一层是全国总览,能看到景点分布的大致格局;第二层是点击省级地区下钻,显示该省的热门城市;第三层是点击城市,展示该城市的景点列表。这个下钻功能在毕设系统里非常亮眼,因为大多数同学做的只是静态展示,你能交互,并且下钻逻辑是通过城市字段筛选实现的,复杂度不高,但演示效果好。
5.2 景点详情页与评论分析
景点详情页集成了三类信息:基本信息、评分趋势、评论词云。基本信息包括景点等级、门票、简介;评分趋势是用户评分按月的折线图;评论词云则提取了全部评论的高频词。
这里的评分趋势折线图有一个数据处理的细节:月份需要连续,比如某个月没有评论,折线上不能出现断层。我处理的方式是先构建一个连续月份列表,然后左连接实际统计数据:
import pandas as pd from django.db.models import Count from comments.models import Comment rows = ( Comment.objects.filter(spot=spot) .values("date") .annotate(total=Count("id")) ) df = pd.DataFrame(list(rows)) df["date"] = pd.to_datetime(df["date"]) df = df.set_index("date").resample("M").sum().fillna(0)resample("M") 是按月重采样的方法,它会自动补全缺失月份,fillna(0) 把空月份填成 0。这样生成的折线图不会有断裂,看起来非常整洁。
5.3 用户系统与收藏功能
虽然这是数据分析系统,但如果没有用户功能,论文里“系统设计”这一章会显得单薄。我实现了注册、登录、退出、收藏景点四个功能,Django 自带的 authenticate 和 login 方法基本够用,不需要引入第三方库。
收藏功能的数据库设计是典型的“一对多关联”:我的收藏表 Favorite 关联了用户和景点,用户和景点之间构成多对多关系。在视图函数里判断当前用户是否已经收藏过某个景点,再决定返回“收藏”还是“取消收藏”的按钮状态。
这里要特别注意登录状态下的权限控制。我用了 Django 的 login_required 装饰器,未登录的用户点击收藏时会被重定向到登录页面,这个逻辑很简单,但项目里必须要有,不然评委测试时会发现漏洞。
5.4 后台管理界面
Django Admin 后台虽然是一句 python manage.py createsuperuser 就能建起来的,但它是整个系统里“数据管理”能力的最好证明。我在 admin.py 里注册了 Spot、Comment、Favorite 三个模型,并配置了 list_display、search_fields、list_filter 等属性。
例如在景点列表里的 search_fields = ["name", "city"],这样管理员能在后台直接搜索景点名称或城市;list_filter = ["level"] 可以按景点等级过滤。这些配置加起来不超过十行代码,但演示时效果很好,能证明这个系统不是只有展示端,还有完整的管理端。
后台还有一个实用功能是数据导出。我重写了 admin 的 actions,增加一个“导出选中景点为 CSV”的按钮,这样管理员可以把任意区间的数据导出成文件。毕设里做完这一步,你可以明确地说系统具备了“轻量数据运营”的能力。
6. 部署上线与问题排查实录
6.1 本地开发环境的搭建顺序
搭建环境我推荐按这个顺序来,可以避免很多兼容性问题:先装 Python 3.10,创建虚拟环境,再装 Django 4.2、mysqlclient、pandas、jieba、requests、BeautifulSoup4、mlxtend。然后创建 MySQL 数据库 travel_db,执行迁移,最后导入清洗好的数据。
这里我特别强调用虚拟环境。很多同学图省事直接用全局环境安装,结果装了一个新包把老包版本覆盖了,项目突然跑不起来,排查半天才发现是依赖冲突。venv 其实只需要一条命令:
python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install -r requirements.txtrequirements.txt 是项目依赖的清单,我用 pip freeze 生成的。这样换一台电脑部署时,只需要一条命令就能装齐所有依赖,避免了“在我电脑上能跑”的尴尬。
6.2 从本地到服务器的部署要点
如果要把项目部署到服务器上,我的建议是使用 Nginx + Gunicorn 的组合。Gunicorn 负责运行 Django 应用,Nginx 负责处理静态文件和反向代理。这个部署过程里最容易出问题的是静态文件收集:Django 开发服务器会自动处理静态文件,但 Gunicorn 不会,你需要先执行 collectstatic 把所有静态文件收集到一个目录,再让 Nginx 去这个目录读取。
部署还有一层安全配置很容易被忽略:关闭 Django 的 DEBUG 模式。生产环境里 DEBUG=True 会暴露完整的错误堆栈和配置信息,这是一个明显的安全风险。正确做法是把 DEBUG 设为 False,并配置 ALLOWED_HOSTS 为你的服务器域名或 IP。
6.3 常见报错与排查速查表
我在开发过程中遇到了不少奇葩问题,整理了一张速查表,遇到类似报错可以直接对照:
| 报错信息 | 可能原因 | 解决方案 |
|---|---|---|
| Unknown column 'xxx' in 'field list' | 数据库表结构和模型不同步 | 执行 python manage.py migrate |
| 中文乱码 | 数据库连接字符集配置错误 | OPTIONS 里设置 charset="utf8mb4" |
| 502 Bad Gateway | Gunicorn 未启动或运行失败 | 查看 gunicorn 日志,确认端口被占用 |
| 数据图表空白 | 接口请求被拦截或数据格式不对 | 打开浏览器控制台,检查 network 面板 |
| jieba 分词卡顿 | 未使用缓存,重复加载词典 | 将分词结果保存到数据库或缓存 |
| 地图组件加载不出来 | GeoJSON 文件路径错误或跨域 | 确认文件已复制到静态目录,检查路径 |
这张表里的前三条都是我自己实际遇到过的。字符集那个问题尤其隐蔽,因为单纯看数据库内容好像没乱码,但页面显示就是出现“?????”,后来我排查到是 MySQL 建表时的默认字符集还是 latin1,通过修改库的字符集才解决。所以建议在建库时直接指定字符集:
CREATE DATABASE travel_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;6.4 性能优化与数据量扩展
项目如果后续要继续扩展,必然要面临性能问题。目前几千条数据量下所有接口都是毫秒级响应,不需要特殊优化。但我在论文里还是预留了一节写“优化方向”,因为评委很可能问“你考虑过数据量增大的情况吗”。
我的优化策略是第一层加 Redis 缓存:热门接口如城市统计、地图数据,设置 5 分钟的过期时间,减少 MySQL 查询压力。第二层是索引优化:给 Spot 表的 city、level 字段和 Comment 表的 spot_id、date 字段分别建索引,这些字段都是高频查询条件,加了索引后查询速度会有明显提升。第三层是异步任务:数据清洗和爬虫如果数据量大,可以通过 Celery 放到后台执行,避免阻塞用户请求。我在系统里没有全部实现这三层,但把思路写清了,答辩时明确说哪些实现了、哪些是未来工作,比硬吹更能取得信任。
7. 论文写作思路与答辩准备
7.1 论文章节如何对应项目
毕设论文的结构和项目实现严格一一对应,评审专家一眼就能看出你的工作量。我的论文章节划分是:绪论、相关技术介绍、系统需求分析、系统设计、系统实现、系统测试、总结与展望。
其中“系统设计”这一章要画架构图、数据库 ER 图、核心模块时序图;“系统实现”这一章按功能模块逐一展开,每个小节对应截图加关键代码;“系统测试”这一章分功能测试和性能测试,功能测试用测试用例表,性能测试记录接口响应时间。图表里我用的是 Visio 和 draw.io,不花钱也足够用。
有一个容易忽视但很重要的点是:论文里的代码不要整段贴,只贴核心函数并加注释。我在论文里贴了 Apriori 调参的那段代码和分析结果表格,这就够了。评审老师看的是你对代码的理解,不是代码篇幅有多长。
7.2 答辩演示的节奏把控
答辩演示是整个毕设的临门一脚,我的建议是控制在十分钟以内,顺序是:先展示数据概览看板,再演示“按城市筛选下钻”的交互,接着打开景点详情页看词云,最后切到后台管理页面展示数据导出的功能。全程不要提“爬虫写得多辛苦”,重点是把分析结果讲清楚。
这里我总结一个被很多同学忽略的点:演示时准备一份“脱机版”页面。所谓脱机版,就是把页面所需的所有数据事先写到一个 JSON 文件里,前端用本地文件代替接口请求。万一答辩现场网络不稳定,地图的 GeoJSON 加载不出来,至少其他图表还能正常展示。这个小细节往往能在关键时刻救你一命。
7.3 关于毕设源码的整理规范
整理源码交付时,我建议按下面的目录结构组织,清晰明了:
TravelAnalysis/ |-- manage.py |-- requirements.txt |-- README.md |-- TravelAnalysis/ # 项目配置目录 |-- spots/ # 景点 app |-- comments/ # 评论 app |-- analysis/ # 分析 app |-- users/ # 用户 app |-- static/ # 静态资源 |-- data/ # 爬虫数据和清洗后的 CSV |-- scripts/ # 爬虫和数据分析脚本 `-- docs/ # 论文和答辩 PPTREADME.md 里写清楚项目简介、安装步骤、运行方式、默认账号密码,这是给评委和导师快速上手用的。data 目录单独放脚本和数据,避免后续运行时误删。整套项目传到代码托管平台之前,记得把 settings.py 里的数据库密码改成环境变量读取的方式,或者至少改成占位符,不要暴露真实的数据库密码。我见过太多人把密码直接写在代码里传上去,这个习惯一定要改。
8. 一些个人经验与后续扩展
这个项目我从搭建骨架到最后写完论文,前后花了大约两个半月。回过头看,最难的不是写代码,而是把从爬虫到图表这条链路的所有环节都跑通。爬虫阶段会遇到各种反爬,清洗阶段会遇到各种脏数据,画图表时会发现经纬度有偏差导致点落在海里的情况,每一步都得单独调试。如果你也正在做类似的毕设,我的建议是先定一个小目标:先抓 100 条数据,完成“抓取-清洗-入库-图表展示”的闭环,再逐步扩展到全量数据。闭环通了,后面的工作就是重复和优化,心态会稳很多。
有一个可以继续扩展的方向是加入基于时间维度的趋势预测。现在系统里展示的是静态的景点热度分布,如果能把历史评分和评论数按时间序列建模,用 ARIMA 或者 Prophet 预测未来一个季度的景点热度变化,这个系统的分析深度会再上一个台阶。我在论文的展望里写了这个方向,但也明确指出当前数据量不足以支撑可靠的时间序列预测,需要积累更长时间的数据才能真正落地。
最后再说一个实用小技巧:做这种数据分析系统,一定要给自己的数据留一份备份。我在开发过程中删库重来了三次,有的是因为迁移冲突,有的是因为清洗脚本误操作。后来我养成了一个习惯,每次清洗完毕后都生成一个新的 CSV 文件,加上日期后缀。这样任何时候数据出了问题,都能从上一版文件重新开始,不至于全部推倒重来。工具和数据本身不难,难的是在反复调试中保持耐心,这一步过了,这个毕设你就已经拿下一半了。