每年到了毕设季,我都能收到一堆“XX系统设计与实现”的咨询,其中旅游类是重灾区。倒不是这个方向不好,而是很多同学做出来的东西只是一个CRUD壳子,没有数据分析、没有推荐逻辑,答辩的时候老师一问“你的推荐系统推荐逻辑是什么”,现场就冷场。今天我把一套完整的django旅游数据分析评价与推荐系统从头到尾拆开讲,从选题背景、数据库设计、推荐算法选型、数据分析可视化,到最终部署上线都过一遍。这套方案是我陪多个学弟反复打磨过的,适合正在做相关毕业设计、或者想用Django完整落地一个带推荐和统计功能项目的人参考,技术栈不花哨,但每一步都能讲清楚理由,答辩也能站得住脚。
1. 这个毕设为什么选Django:需求拆解与技术选型
1.1 旅游分析与推荐,到底做的是什么
先说清楚这个项目要解决的业务问题。用户打开一个旅游平台,最基础的需求是搜景点、看评价、订门票。但如果你只是实现这些功能,那它就是一个普通信息管理系统,撑不起“数据分析评价与推荐”这个题目。真正加分的点是:平台能根据用户过去的浏览、收藏、评分行为,自动推荐他可能感兴趣的景点;同时后台能对景点评分、评论数、城市热度、用户偏好进行统计可视化,让管理员一眼看清平台运行状况。
一句话概括,系统分三层:基础业务层(登录注册、景点展示、评价收藏)、数据统计层(评分分布、城市热度、分类占比)、个性化推荐层(协同过滤挖掘兴趣)。这三层叠在一起,才是一个能打动答辩老师的完整选题。
1.2 技术栈怎么配:Django为主,再带哪几个辅助
后端选Django几乎是这个场景下的最优解,原因很实在。第一,Django自带ORM、Admin后台、用户认证体系,Bootstrap级别的联调工作省掉一大半。第二,整个推荐和数据分析模块都在Python生态里,pandas、numpy、scikit-learn这些库随手就能用,不需要额外起服务。第三,Django模板引擎可以直接渲染图表数据,毕设项目没必要强行前后端分离。
辅助技术我给这套方案的标配是:
- Python 3.8以上 + Django 3.2或4.x,建议LTS版本,坑少。
- MySQL 8.x做业务数据库,不要用SQLite跑毕设,虽然本地方便,但生产部署和演示效果差距很大。
- Redis选装,主要是做缓存和Session,如果只是演示,不装也能跑通。
- ECharts做前端可视化,比Matplotlib出图高端不少,而且是动态交互的,答辩演示效果好。
- 推荐算法模块不依赖重型框架,自己实现协同过滤,算法逻辑可解释性强,这在答辩时非常加分。
我见过不少同学一上来就配Django REST Framework + Vue前后端分离,结果卡在跨域、打包、接口联调上,进度一拖再拖。我的建议是:毕业设计优先保证完整度,模板渲染 + 少量原生JavaScript完全够用,把精力留给推荐算法和数据分析这些真正有技术含量的部分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据库建模:把“用户-景点-评价”三条业务主线理清
2.1 核心数据表设计与关系梳理
数据库设计是整个系统的地基,表关系理不清,后面写ORM、做统计、跑推荐全都会绊脚。基于这个项目,我设计的是六张核心业务表,加上Django自带的用户表。
第一张是景点表scenic_spot,字段包括景点名称、所在城市、分类(自然风光/人文古迹/主题乐园等)、门票价格、封面图、景点介绍、平均评分、评论数。第二张是评价表review,关联用户和景点,记录评分(1到5分)、评价内容、评价图片、创建时间。第三张是收藏表favorite,记录用户对景点的收藏关系。第四张是浏览历史表browse_history,记录用户访问过哪些景点。
这里有个关键设计细节:景点表里我特意加了avg_score和comment_count两个冗余字段。为什么不直接实时去评价表里count和avg?因为景点列表页、推荐位、详情页到处都要展示这两个值,如果每次请求都去统计一次,数据量上来后响应时间会明显变长。采用冗余字段配合评价新增时同步更新,是一个经典的空间换时间思路,这个点在文档和答辩时提出来很加分。
2.2 Django ORM建模的细节与坑
直接用Django ORM定义模型,核心代码大致长这样:
python复制from django.db import models
from django.contrib.auth.models import User
class ScenicSpot(models.Model):
name = models.CharField('景点名称', max_length=100)
city = models.CharField('城市', max_length=50)
category = models.CharField('景点分类', max_length=50)
price = models.DecimalField('门票价格', max_digits=8, decimal_places=2, default=0)
description = models.TextField('景点介绍', default='')
image = models.ImageField('封面图', upload_to='scenic/', blank=True)
avg_score = models.FloatField('平均评分', default=0)
comment_count = models.IntegerField('评论数', default=0)
created_at = models.DateTimeField('创建时间', auto_now_add=True)
class Meta:
db_table = 'scenic_spot'
class Review(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='用户')
scenic = models.ForeignKey(ScenicSpot, on_delete=models.CASCADE, verbose_name='景点')
score = models.IntegerField('评分', default=5)
content = models.TextField('评价内容', default='')
images = models.ImageField('评价图片', upload_to='review/', blank=True)
created_at = models.DateTimeField('评价时间', auto_now_add=True)
class Meta:
db_table = 'review'
建模有两个坑要提醒你。第一个是外键级联删除:用户删除账号或者景点下架时,关联的评价、收藏、浏览记录要一并处理,所以外键全部用on_delete=models.CASCADE,否则会报完整性错误或者留下脏数据。第二个坑是ImageField依赖Pillow库,项目启动后第一件事就是装Pillow,不装的话一执行makemigrations就报错。另外上传图片还要配置MEDIA_URL和MEDIA_ROOT,很多同学做完上传功能后图片显示404,基本都栽在这里。
3. 推荐系统核心模块:协同过滤的选型与落地实现
3.1 三类常见推荐算法,旅游场景该选哪个
推荐系统是这套毕业设计的技术核心,也是老师必问的模块。常见的推荐算法有三种:基于内容的推荐(Content-Based)、基于用户的协同过滤(UserCF)、基于物品的协同过滤(ItemCF)。
基于内容推荐适合有丰富标签信息的新平台,比如景点有分类、城市、特性标签,给用户推荐属性相似的景点。好处是冷启动友好,但推荐结果偏保守,永远在那个用户已有的兴趣圈里打转。基于用户的协同过滤计算用户之间的相似度,把相似用户喜欢的物品推荐过来,适合信息流平台,但用户量少的时候矩阵特别稀疏,效果不稳定。基于物品的协同过滤则是计算物品之间的相似度,给用户推荐和他看过的景点相似的其他景点,在旅游场景下是最稳的选择。
我的选型结论是:以基于物品的协同过滤(ItemCF)为主,叠加地域和热度规则。原因很简单,旅游行为的典型特点是“用户出行次数少但决策重”,用户之间行为相似性很弱,但景点之间的关联性强——去过杭州西湖的人大概率对灵隐寺、西溪湿地也感兴趣。在行为数据稀疏的情况下,ItemCF比UserCF更稳定。热度规则用来兜底冷启动,新用户没有行为记录时推荐热门景点,这符合大众认知,也容易被答辩老师接受。
3.2 基于物品的协同过滤,核心代码怎么写
推荐模块核心逻辑自己用Python写并不复杂,关键几步是:读出用户-景点评分矩阵、计算景点之间的相似度、根据相似度预测打分、生成Top-N推荐列表。
python复制# recommend/item_cf.py
import numpy as np
from collections import defaultdict
def build_user_item_dict(reviews):
"""把评价数据转成 {user_id: {item_id: score}} 结构"""
user_items = defaultdict(dict)
for uid, iid, score in reviews:
user_items[uid][iid] = score
return user_items
def calc_item_similarity(user_items):
"""基于余弦相似度计算物品相似度矩阵"""
item_users = defaultdict(set)
for uid, items in user_items.items():
for iid in items:
item_users[iid].add(uid)
co_count = defaultdict(lambda: defaultdict(int))
for iid, users in item_users.items():
for uid in users:
for other_iid in user_items[uid]:
if other_iid != iid:
co_count[iid][other_iid] += 1
sim_matrix = defaultdict(dict)
for iid, related in co_count.items():
for other_iid, count in related.items():
# 余弦相似度,这里用共现次数除以两侧用户数乘积的平方根
sim_matrix[iid][other_iid] = count / np.sqrt(
len(item_users[iid]) * len(item_users[other_iid])
)
return sim_matrix
def predict_score(user_id, item_id, user_items, sim_matrix, top_k=10):
"""预测用户对未评分物品的打分"""
if user_id not in user_items:
return None
rated_items = user_items[user_id]
sim_list = sorted(sim_matrix.get(item_id, {}).items(),
key=lambda x: x[1], reverse=True)[:top_k]
numerator, denominator = 0.0, 0.0
for other_item, sim in sim_list:
if other_item in rated_items:
numerator += sim * rated_items[other_item]
denominator += sim
if denominator == 0:
return None
return round(numerator / denominator, 2)
为什么用余弦相似度而不用皮尔逊相关系数?因为在旅游评分数据里,绝大多数用户只有零星几条评价,皮尔逊相关系数对“只有两个共同评分项”的用户对极其敏感,很容易算出极值,反而不如余弦相似度稳定。这里的相似度公式其实就是“共同喜欢两个景点的人数 / 两个景点各自被喜欢人数的乘积开根号”,分母惩罚了热门景点,防止故宫、黄山这种大热门把所有东西都带偏。
3.3 冷启动与数据稀疏问题的处理
推荐系统最怕数据稀疏,一个新建的毕设项目,用户才几十个,评论几百条,协同过滤算出来的相似度矩阵大量是零。我的处理策略是三层兜底:
第一层,新用户没行为数据走默认推荐,按景点综合热度排序,热度分 = 0.6 * 平均评分 + 0.4 * 标准化评论数,这样既不全是销量榜,也不会被个别10分好评刷屏。第二层,新景点没有评分记录时,通过分类和城市字段做基于内容的相似推荐,也就是说哪怕一个景点零评论,只要它和用户看过的景点同城或者同分类,就能被捞出来。第三层,推荐列表生成后必须过滤掉的几类:用户已评论过的、已收藏的、价格超出用户历史订单价格1.5倍的景点。这个细节特别重要,直接展示在推荐列表里会显得系统很傻,答辩也容易被挑刺。
推荐接口的设计上,建议单独封装成一个recommend_for_user(user_id, n)函数,视图函数只负责取参和返回结果。这样推荐逻辑可以独立测试,也方便后续把算法替换成基于矩阵分解的更复杂版本,代码结构上更干净。
4. 数据分析与评价可视化:从原始数据到图表
4.1 数据分析的几个关键维度
数据分析模块是“评价”两个字背后的重头戏。我当时给学弟定的分析维度是四个:景点评分分布、热门景点Top10、城市热度排行、用户活跃趋势。评分分布用直方图展示,可以看出平台的评分是否存在异常集中,比如全是5分,这说明评价数据可能有水分。热门景点Top10按评论量和平均评分双指标展示,反映出真实热门。城市热度排行用柱状图加地图效果,一眼看出哪些城市旅游供给最丰富。用户活跃趋势按天统计注册和评价数量,可以分析出平台生命周期。
数据分析不能只做图表展示,还要能下钻。比如点了热门景点榜单中的某个景点,应能联动展示这个景点的评分分布和评价关键词。我在系统里做了一个简单的筛选联动,前端传景区ID给后端,后端返回该景区的统计JSON,再局部刷新图表。这种交互设计看上去工作量不大,但演示时非常唬人,老师会觉得你的数据是活的,不是写死的。
4.2 pandas清洗数据 + ECharts可视化的完整链路
数据分析的完整链路不复杂:ORM查出数据,转成DataFrame清洗聚合,再序列化成JSON传给前端ECharts渲染。以城市热度分析举例:
python复制import pandas as pd
import json
from django.http import JsonResponse
from .models import Review
def city_stats_api(request):
# 从评价表关联景点表取城市和评分字段
values = Review.objects.values('scenic__city', 'score')
df = pd.DataFrame(list(values))
# 清洗:去掉城市为空或评分不在1-5之间的脏数据
df = df.dropna(subset=['scenic__city'])
df = df[(df['score'] >= 1) & (df['score'] <= 5)]
# 按城市聚合
stats = df.groupby('scenic__city').agg(
avg_score=('score', 'mean'),
comment_count=('scenic__city', 'count')
).sort_values('comment_count', ascending=False).head(15)
data = {
'cities': stats.index.tolist(),
'avg_scores': [round(v, 2) for v in stats['avg_score']],
'comment_counts': stats['comment_count'].tolist(),
}
return JsonResponse(data)
前端用ECharts做一个双Y轴图,左边柱状图显示评论量,右边折线图显示平均分,这样一张图就能看出“哪些城市评价多且口碑好”,比单一图表信息量大很多。
html复制<div id="cityChart" style="width: 100%; height: 420px;"></div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<script>
fetch('/api/city-stats/')
.then(res => res.json())
.then(data => {
const chart = echarts.init(document.getElementById('cityChart'));
chart.setOption({
tooltip: { trigger: 'axis' },
legend: { data: ['评论量', '平均评分'] },
xAxis: { type: 'category', data: data.cities },
yAxis: [
{ type: 'value', name: '评论量' },
{ type: 'value', name: '平均评分', max: 5 }
],
series: [
{ name: '评论量', type: 'bar', data: data.comment_counts },
{ name: '平均评分', type: 'line', yAxisIndex: 1, data: data.avg_scores }
]
});
});
</script>
这里有个技巧,Django模板中内嵌JSON数据时一定要用escapejs过滤转义,不然数据里含引号或特殊符号,前端直接断掉。如果走fetch接口,就不用担心这个问题,所以我更倾向于这种方式。数据清洗部分,我用pandas主要是为了代码简洁和答辩讲解方便,实际数据处理量不大,直接用Python内置函数同样能做,但pandas的groupby一行解决聚合,效率高很多。
5. 系统功能拆分与前端交互实现
5.1 功能模块清单与权限控制
把系统按角色拆开,前台用户和管理员看到的完全不一样。前台模块包括:注册登录、景点列表与条件筛选(按城市、分类、价格区间)、景点详情、评价列表与发布评价、收藏与取消收藏、我的收藏、我的评价、浏览记录,以及推荐位。管理员后台直接复用Django Admin,再补充一个数据可视化看板展示各类统计图表。
权限控制这一块要写好,不能只靠前端藏按钮。视图层统一用@login_required装饰器保护需要登录的接口,比如发布评价、收藏操作;模板里用{% if user.is_authenticated %}控制按钮展示。还要注意,评价发布接口一定要校验当前操作人是不是登录用户本人,防止通过拼接接口给别人的景点刷评价。
Django自带User表已经有完善的密码加密和Session管理,不需要自己重新造轮子。扩展用户信息(比如头像、昵称、偏好标签)建议建一个Profile表跟User一对一关联,不要直接改Django源码里的User模型,迁移时会踩坑。
5.2 图片上传、评价发布等高频细节
图片上传又是一个重灾区。Django的ImageField本身存的是路径字符串,真正要能访问到文件,需要两步配置。第一步在settings.py里配MEDIA_URL = '/media/'和MEDIA_ROOT = os.path.join(BASE_DIR, 'media')。第二步在主路由urls.py里加媒体路由映射:
python复制from django.conf import settings
from django.conf.urls.static import static
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
有一个非常隐蔽的坑:当Debug=False走生产模式时,Django不再托管media文件,图片全挂。所以在上线部署时,需要在Nginx配置里单独加一个/media/的location指向项目media目录,很多同学漏了这一步,结果本地好好的,一上线图片全裂。
评价发布我用的是ModelForm加表单校验,前端做星级评分组件,后端再校验一次评分必须在1到5之间,防止直接curl提交脏数据。评价成功之后,要同步更新景点表的avg_score和comment_count,这个更新逻辑放在模型信号(signal)里做,或者直接在视图里同步操作,但千万别在模板里现算,性能太差。
6. 环境搭建与上线部署:本地跑通到服务器发布
6.1 虚拟环境与Django基础操作
很多同学第一步就卡在环境上。这里把毕设最常见的几个操作列一遍。创建虚拟环境用python -m venv venv,Windows下激活是venv\Scripts\activate,Linux或Mac是source venv/bin/activate。如果项目不用了,删虚拟环境直接删整个venv目录就行,没有额外的反注册操作。如果用了Conda,想删环境是conda env remove -n 环境名。
激活环境后,在项目根目录执行pip install -r requirements.txt把依赖一次装齐。创建Django项目是django-admin startproject config .,注意那个点不要漏,它表示在当前目录生成配置文件。创建应用程序是python manage.py startapp apps,然后记得在settings.py的INSTALLED_APPS里注册。做完模型改动后,依次执行python manage.py makemigrations和python manage.py migrate。这两个命令的区别很多同学分不清,makemigrations是把模型变化生成迁移文件,migrate才是真正把迁移文件同步到数据库,只执行前者不执行后者,表是不会建出来的。
6.2 宝塔面板部署全流程
本地跑通只是第一步,毕业设计最后还要演示部署,或者放到服务器给老师在线访问。我用得最顺手的服务器部署方案是宝塔面板 + Gunicorn + Nginx。流程不复杂:
先把代码传到服务器,建议用Git拉下来,这样后面修改代码只需要git pull。在宝塔里装Python项目管理器,创建Python版本,安装依赖。然后配置启动命令,我用的是gunicorn config.wsgi:application -b 127.0.0.1:8000 -w 2,两个worker进程对毕设项目完全够用。如果遇到gunicorn没安装,记得pip install gunicorn。
Nginx反代是核心步骤,把服务器的80端口请求转发到本地的8000端口,同时处理静态文件和媒体文件。配置关键段如下:
nginx复制server {
listen 80;
server_name 你的域名或IP;
location /static/ {
alias /www/wwwroot/你的项目/static/;
}
location /media/ {
alias /www/wwwroot/你的项目/media/;
}
location / {
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;
}
}
配置完后还有个必做动作:执行python manage.py collectstatic把Django框架自带的后台样式、静态文件都收集到STATIC_ROOT目录,不然Admin后台打开全是乱的。settings.py里记得把ALLOWED_HOSTS改成你的域名或服务器IP,否则访问会直接报Bad Request,这个错很常见,原因却好多人不知道。
6.3 我踩过的几个高频坑
把最常见的坑整理成一个速查表,照着排查能省不少时间:
| 常见问题 | 主要原因 | 解决办法 |
|---|---|---|
| 图片上传后访问404 | 未配置MEDIA_URL或未加媒体路由 | settings.py配MEDIA_ROOT,主urls.py加static映射 |
| 本地正常上线后样式全丢 | Debug=False时Django不托管静态文件 | 执行collectstatic并把static目录交给Nginx |
| 后端返回中文乱码 | MySQL表字符集不是utf8mb4 | 建库时指定utf8mb4,settings里配置CONN_MAX_AGE和OPTIONS中的charset |
| delete()删不掉数据或报错 | 混淆了QuerySet.delete()和对象.delete() | QuerySet是批量删除,对象.delete()是单条删除,都有返回值 |
| 端口被占导致启动失败 | 上次服务没关干净 | lsof -i:8000查出PID再kill |
| 虚拟环境激活后pip还是系统的 | 激活失败或路径不对 | 检查which python确认是否指向venv路径 |
Django执行查询和删除对象的细节再强调一次。如果要删除某个对象的关联数据,比如删除一条评价,controller里应该先取对象再删除:Review.objects.get(id=review_id).delete()。如果想批量删除某个用户所有评价:Review.objects.filter(user_id=uid).delete()。前者返回元组(1, {'review.Review': 1}),后者返回删除数量和详情,这两个返回值的含义不同,毕设代码里写清楚注释,老师看到会觉得你理解到位。
最后再分享一个我觉得最实用的经验:一套旅游数据分析评价与推荐系统,真正拉开档次的地方不是页面多华丽,而是推荐逻辑有没有闭环、数据分析有没有洞察。把协同过滤的公式推导写明白,把仪表盘的每个图表含义解释透,答辩的时候即使代码有点小瑕疵,老师也会认为你有真实的技术思考。很多同学把重心放在登录注册上,结果最核心的推荐模块只有一句“调用了算法库”,这样反而丢了西瓜。如果你也是从零开始做这个题目,建议按我上面的顺序推进,先建模再推荐,最后补图表,每一步做完都能看到阶段性成果,心态也不容易崩。
