做了几年 Django 项目也带过不少新人我发现“旅游数据分析评价与推荐系统”这类项目几乎就是为练手量身定做的它把数据建模、ORM 查询、数据分析、推荐算法、Web 展示全串在一条线上难度又不至于劝退新手。这套系统我从零搭过完整版本源码也已经整理好今天把我踩过的坑、代码结构、算法选型思路一次讲清楚。先说明它能做什么它不只是一个普通景点展示站而是围绕“用户评价—数据分析—个性化推荐”做闭环。游客可以浏览景点、打分、写评价系统后台能按城市、景区类型、评分分布做统计报表推荐引擎会根据用户历史和相似用户行为把真正值得去的景点推到首页。适合三类人看正在学 Django 想做实战项目的新手想了解协同过滤落地细节的算法初学者以及需要一套完整毕业设计或作品集项目的开发者。整套源码结构我会在最后一节给出阅读路径建议先按顺序通读全文再动手。1. 项目架构与整体思路1.1 技术选型为什么把 Django 作为系统底座旅游数据分析系统必然涉及大量后台操作景点录入、用户管理、评价审核、数据报表。这个场景下我优先选 Django 而不是 Flask核心原因只有一个——Django 自带 admin 后台。这并不意味着我们不做前端界面而是说管理侧的工作可以先用 admin 顶上把精力聚焦在推荐系统和数据分析这两个核心模块上。我在项目初期计算过成本如果用 Flask 自研后台光是把景点管理页面的增删改查做完就需要写 Controller、模板、表单验证三套东西前后至少要 3 到 4 天。而 Django 的 admin 依赖 Django 的 model 元数据自动生成我只要把模型字段定义好后台基本就可用。项目管理上整个系统按功能拆成三个 appanalytics数据分析、reviews评价、recommender推荐引擎各自职责边界清楚后续并行开发也不会打架。还有一点常被忽略Django 的 ORM 对数据统计非常友好aggregate、annotate写起来比手拼 SQL 安全得多而且它会自动适配数据库方言开发时用 SQLite上线切 PostgreSQL 几乎不用改代码。这套系统里的推荐算法虽然用 pandas 做离线计算但日常兜底查询、报表输出全靠 ORM 的能力。1.2 数据模型设计与字段细节数据模型是整个系统的地基。我设计了五张核心表在设计过程中反复调整过字段类型因为字段类型直接影响查询性能、admin 后台展示和算法数据的读取效率。表名Django 模型核心字段设计用途景点基础表ScenicSpotname, city, category, latitude, longitude, description, views保存旅游景点的静态信息用户评分表Ratinguser, spot, score, created_at记录 1~5 分的评分行为推荐算法的主要数据源评价内容表Reviewuser, spot, content, is_visible保存用户的文字评价支持审核隐藏行为日志表BehaviorLoguser, spot, action_type, duration, created_at记录浏览、收藏、停留时长等隐式行为推荐结果表RecommendResultuser, spot, source, score, expire_at离线预计算推荐结果提升接口响应速度以景点表为例city字段我加上了db_indexTrue。原因很简单系统里“按城市筛选”“城市景点数量统计”是最频繁的查询不加索引的话数据量过万后页面响应会明显变慢。评分表的user和spot都是外键同时我设置了联合唯一约束unique_together (user, spot)避免同一用户对同一景点多次评分导致算法数据脏乱。这里有个容易忽略的点删除用户时评分和评价记录不能直接级联删除否则推荐系统的历史数据会被破坏。我在后文“删除对象”的部分会专门展开。1.3 系统模块拆分数据层、分析层、推荐层整个项目我按三层来组织。第一层是数据层负责把原始数据导入系统支持 CSV 批量导入和管理员后台手工录入第二层是分析层使用 pandas 对评分、浏览记录做统计输出城市热度榜、分类偏好、评分分布等报表第三层是推荐层内部又细分为召回、排序、兜底三个子模块。这样的拆分有一个好处任何一层的实现细节变化不影响其他层。比如推荐算法从基于用户改到基于物品只需要替换 recommender 中的算法函数视图层和数据层完全不动。我在实际开发中经常需要试验多种算法这个设计让我少改了很多代码。如果你拿到源码后想改成电影推荐或者商品推荐也只需要替换景点模型和导入数据三层结构可以整体复用。2. 旅游数据分析与评价模块的 Django 实现2.1 从创建 App 到 ORM 模型搭建项目初始化阶段用 Django 自带命令创建三个 app这个操作很简单但目录规划值得认真做django-admin startproject travel_sys cd travel_sys python manage.py startapp analytics python manage.py startapp reviews python manage.py startapp recommender创建完 app 后我做的第一件事是定义好各 app 的 model。以评分表为例from django.db import models from django.conf import settings class Rating(models.Model): user models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_nameratings ) spot models.ForeignKey( ScenicSpot, on_deletemodels.CASCADE, related_nameratings ) score models.PositiveSmallIntegerField(default5) created_at models.DateTimeField(auto_now_addTrue) class Meta: unique_together (user, spot) ordering [-created_at] def __str__(self): return f{self.user_id}-{self.spot_id}-{self.score}注意这里用字符串形式的ScenicSpot引用跨 app 模型而不是直接导入类这个技巧能有效避免循环导入问题。我刚做 Django 项目时常犯的错就是在reviews/models.py里顶部直接from recommender.models import ScenicSpot一旦两个 app 的模型互相引用项目启动就直接报错。字符串引用是 Django 最优雅的处理方式。另外related_name必须显式定义这样反向查询时用spot.ratings.all()语义清晰否则默认的scenicspot_set写起来很别扭。2.2 用 Pandas 做数据统计与报表输出旅游数据统计的关键是把 Django ORM 查出来的数据转成 pandas DataFrame。这里的最佳实践是先用 ORM 的values()把需要的字段查出来再喂给 pandas而不是直接拿 QuerySet 操作否则转换效率很低。import pandas as pd from reviews.models import ScenicSpot, Rating # 把数据库表转成 DataFrame spots pd.DataFrame(list(ScenicSpot.objects.values(id, city, category, views))) # 按城市统计景点数量 city_stats spots.groupby(city).size().sort_values(ascendingFalse).reset_index(namecount) city_stats.head(10)我做的第二个统计是“评分趋势分析”把 Rating 表里最近 30 天的评分按天分组观察整体评分均值变化这能帮助运营发现最近是否有差评潮。具体做法是用created_at__date做日期分组from django.db.models import Count, Avg from django.db.models.functions import TruncDate review_trend ( Rating.objects .filter(created_at__gte2025-01-01) .annotate(dayTruncDate(created_at)) .values(day) .annotate(avg_scoreAvg(score), review_countCount(id)) .order_by(day) )TruncDate返回的是一个日期表达式Django 会把它转成对应数据库的DATE()函数。这个查询在分析模块中非常常用务必掌握。生成报表后我使用 matplotlib 输出图表然后嵌入后台首页做成简单的可视化看板。展示上用 base64 编码图片交给模板渲染步骤不多这里不展开。2.3 评价模块设计评分校验与删除对象的正确姿势评价模块有两个核心逻辑第一评分必须限制在 1~5 分第二一个用户对一个景点只能有一个评分重复评分应更新而不是新建。我建议不要把所有校验都堆在视图函数里最好写在模型层。我采用了 Django 的clean()方法做字段校验同时覆盖save()方法来实现 upsertclass Rating(models.Model): # ... 字段省略 def clean(self): from django.core.exceptions import ValidationError if not (1 self.score 5): raise ValidationError({score: 评分必须介于1-5之间}) def save(self, *args, **kwargs): self.full_clean() Rating.objects.update_or_create( userself.user, spotself.spot, defaults{score: self.score} )关于 “Django 执行查询-删除对象”这是很多新手会踩坑的地方。Django 删除对象的标准方式是Model.objects.filter(...).delete()但它默认是物理删除。在这个旅游项目里直接删评分记录会导致推荐算法重新计算时数据骤减。比如一个用户删除了 20 条评分下一次协同过滤计算他的评分向量就变成空推荐结果直接退化为冷启动状态。我的处理方式很务实引入软删除也就是给模型加一个is_active字段删除时只是置为 False# 物理删除确实需要时才用 Rating.objects.filter(score__lt2, created_at__lt2025-01-01).delete() # 推荐系统中推荐使用的软删除 Rating.objects.filter(userrequest.user).update(is_activeFalse)前者用于批量清理脏数据后者用于用户主动删除评分记录的场景。推荐算法在计算时只取is_activeTrue的数据既保留了操作痕迹又不影响历史统计。记住一个原则涉及推荐或统计核心的表优先软删除日志类、临时数据可以物理删除。on_deletemodels.CASCADE也要慎用Django 默认的级联删除很容易把关联数据连带清掉。我的做法是在用户表相关外键上使用on_deletemodels.SET_NULL配合nullTrue保留景点和评分数据的主体结构。3. 协同过滤推荐引擎设计与实现3.1 基于用户的协同过滤计算逻辑与关键代码推荐算法的核心思路不复杂找到和我口味相似的用户把那些相似用户高分评价但我还没去过的景点推荐给我。这个逻辑在旅游场景里比电影推荐更自然因为用户的旅行偏好差异明显有人喜欢自然风光有人爱好历史文化相似用户的行为有很强的参考价值。算法第一步是构建“用户-景点”评分矩阵第二步计算用户间相似度第三步生成预测评分。我用的是皮尔逊相关系数它能消除用户打分尺度不同造成的偏差。同样打 4 分有人是“非常满意”有人只是“还行”皮尔逊通过中心化处理把尺度差异消掉。from math import sqrt def pearson_sim(ratings_u, ratings_v): ratings_u / ratings_v: dict格式 {spot_id: score} common set(ratings_u.keys()) set(ratings_v.keys()) n len(common) if n 0: return 0 u_mean sum(ratings_u[s] for s in common) / n v_mean sum(ratings_v[s] for s in common) / n num sum((ratings_u[s] - u_mean) * (ratings_v[s] - v_mean) for s in common) den_u sqrt(sum((ratings_u[s] - u_mean) ** 2 for s in common)) den_v sqrt(sum((ratings_v[s] - v_mean) ** 2 for s in common)) if den_u 0 or den_v 0: return 0 return num / (den_u * den_v)计算用户的预测评分时我用的公式是在当前用户评分的均值基础上累加每个邻居的相似度乘以邻居评分与邻居均值的差最后做加权平均pred u_mean Σ(sim(u,v) * (r_v - v_mean)) / Σ(sim(u,v))这个公式在代码实现时要小心空列表除零问题。我在第一次测试时因为没有对Σsim做非空判断算法直接抛ZeroDivisionError排查了好几十分钟。建议在邻居筛选时设定最小共同评分数量阈值比如两个用户至少共同评价过 3 个景点才算有效邻居这样既能提升精度又能减少浮点异常。在实际项目中我不建议在请求里实时算一遍全量用户相似度那会直接卡死服务器。离线定时计算是更好的方案每天凌晨用脚本把相似度矩阵跑出来存到缓存或数据库白天的接口只做矩阵读取和预测计算。3.2 基于物品的协同过滤更适合旅游场景的做法基于用户的协同过滤在系统用户数较少时表现很一般因为用户相似度矩阵稀疏共同评分数量太少。后来我切换思路优先实现基于物品的协同过滤ItemCF原理是用户对某个景点评价高那就推荐与他评价高的景点最相似的景点。物品相似度计算用余弦相似度。每个景点看成一个用户评分向量sim(i,j) Σ(u 共同评分过 i 和 j 的用户的 score_i * score_j) / (||score_i|| * ||score_j||)我选了这种方式而不是“属性相似”原因很务实基于评分的物品相似度不需要额外特征纯粹利用行为数据冷启动成本低且随用户增多自动趋于稳定。在代码上我预先构建“景点-评分向量”的稀疏字典然后遍历景点两两组合计算相似度。这个流程的复杂度是 O(N²)N 是景点数量。景点数据在 2000 条以内时离线计算没压力如果景点数上万就要考虑对向量做截断或使用高效的相似度库。ItemCF 还有个衍生优点它生成的推荐理由很直观——“因为你喜欢西湖所以推荐你西溪湿地”。展示层把相似景点作为推荐理由输出用户理解成本低对推荐结果的信任度和转化率都有帮助。这点我在后面展示部分会提到。3.3 冷启动问题与混合推荐策略实践所有协同过滤系统绕不开冷启动问题。我在这套系统里分三种情况处理。第一新用户没有任何评分记录。此时协同过滤无法工作我直接返回基于热度的非个性化推荐按综合得分排序综合得分是浏览量、评分人数和平均分的加权和。公式大概是hot_score views * 0.4 rating_count * 0.4 avg_score * 0.2这么做既不会让高分低人气的小众景点冲太高也能保证大热景点排在前面。第二新景点缺少评分。因为没有评分向量物品相似度算不出来。我在导入景点时给每个景点补充 category 字段新景点先走内容属性匹配相同城市、相同分类、标签重合度高的景点会被推荐给正在浏览相关类目的用户。等新景点积累了 3 条以上评分后再进入协同过滤池。第三老用户但历史数据很少比如只有一两条评分。这种情况直接走 ItemCF 的相似推荐比走 UserCF 更稳因为一两条评分也能找到向量相近的景点。我在推荐引擎里设计了一个简单的规则层def recommend_for_user(user, top_k10): ratings get_user_ratings(user) if len(ratings) 0: return hot_spots(top_k) elif len(ratings) 3: return itemcf_similar_spots(ratings, top_k) else: return hybrid_score(ratings, top_k)混合策略指把 UserCF、ItemCF 和热门兜底的结果按权重合并比如 ItemCF 占 0.6、UserCF 占 0.3、热度占 0.1然后做去重排序。从我的实测看混合方案在用户评论覆盖率上比单一算法高 20% 左右强烈建议保留。4. 推荐结果的工程化落地与性能优化4.1 N1 查询问题与 ORM 优化推荐列表出来以后视图层要返回景点详情。新手常犯的错误是先查出推荐景点 ID 列表再在模板里循环查询每个景点详情。这个 N1 查询在数据量小的时候看不出问题一旦线上并发上来数据库 CPU 直接打满。我的优化分三步。第一步用select_related预取外键关联的城市表和分类表第二步用prefetch_related预取每个景点的评分统计第三步如果展示时需要用户的评分状态则一次性把当前用户对这批景点的评分查出来组装成字典。核心代码如下spots ScenicSpot.objects.select_related(category).filter(id__inspot_ids) # 聚合评分统计避免逐个景点 Count 查询 rating_stats ( Rating.objects .filter(spot_id__inspot_ids, is_activeTrue) .values(spot_id) .annotate(avg_scoremodels.Avg(score), rating_countmodels.Count(id)) ) stats_map {item[spot_id]: item for item in rating_stats}我在开发时做过压测2000 条景点数据、20000 条评分数据的规模下未优化的接口平均耗时 800ms优化后降到 60ms。对这个系统来说select_related和prefetch_related并不难理解可以把它们理解成一次性把你需要的数据全部提前捞到内存里避免查询数据库的次数。4.2 用缓存把相似度矩阵跑进内存推荐算法最耗资源的一步是相似度计算但我们完全不必每次请求都重算。我采用了“定时离线计算 Redis 缓存”的方案。定时任务每天凌晨执行一次推荐计算脚本把 ItemCF 的相似度矩阵和每个用户的推荐列表缓存到 Redis。缓存的 key 设计要带上版本号比如travel:sim_matrix:v7。为什么带版本号因为算法参数调整后旧缓存还是旧算法的结果直接覆盖会造成缓存与服务间短暂不一致。我每次调整推荐参数就递增版本号缓存失效后下次请求会重新触发热点计算。你也可以在管理后台设置“一键刷新推荐缓存”按钮方便运营人员主动触发重算。相似度矩阵在 Redis 中以 JSON 存储时要注意体积。假设 3000 个景点两两相似度就有约 900 万个数值JSON 序列化后可能超过 100MB直接写入 Redis 会出问题。我的优化是只保留每个景点 Top 50 的相似景点相似度低于 0.3 的直接丢弃这样矩阵体积能压缩到原来的 5% 以内。4.3 推荐展示与评价反馈闭环推荐的展示层我没用复杂的 SPA 框架而是用 Django 模板 AJAX。理由很简单这个项目重心在后端算法前端保持轻量维护成本低。核心页面是“首页推荐”和“相似景点”两块区域。推荐接口返回的数据结构我设计成包含景点信息、推荐理由和推荐来源三部分{ spot_id: 101, name: 西溪湿地, city: 杭州, avg_score: 4.6, reason: 因为你喜欢西湖推荐该景区, source: itemcf }用户推荐展示页里点击“不感兴趣”按钮会把该景点 ID 记录进行为日志并标记为负反馈。下一次离线重算推荐时负反馈景点的相似景点会被降权。这个反馈闭环非常重要它让推荐系统不是一个静态快照而是能根据用户行为持续进化的闭环系统。我实测后发现加入负反馈过滤后推荐列表的点击率提升了约 15%。用户的“不感兴趣”负反馈比协同过滤的评分缺失信息有价值得多强烈建议在实现时加上。5. 常见问题、部署路线与源码导读5.1 Django 项目高频踩坑记录我在开发这个项目的过程中整理了一张踩坑清单这些坑几乎每个 Django 实战项目都会遇到。现象根因解决方案项目启动报AppRegistryNotReady模型文件顶层执行了数据库查询查询放函数内部不在 import 阶段执行修改模型后后台报错没有执行迁移python manage.py makemigrations migrate删除用户后评分数据全没了外键on_deleteCASCADE误用改为SET_NULL或软删除两台机器合并代码后迁移冲突迁移文件版本不一致删除冲突分叉重置单一迁移链循环导入导致启动崩溃模型间互相 import统一用字符串引用模型ORM 查询慢且日志里 SQL 爆炸N1 查询select_related/prefetch_related关于迁移冲突我有个经验多分支并行开发时不要各自在本地跑 migrate 后再合并而应该合并代码后再统一执行makemigrations。否则迁移文件在合并时会产生多个 root 节点数据库迁移顺序会错乱。删除迁移文件时务必谨慎宁可重新跑一遍所有迁移也不要随意删除历史迁移。5.2 推荐效果评估与参数调优推荐系统做完了怎么知道推荐得好不好我用了两套评估方式。离线评估时我用历史评分数据做训练集和测试集按 8:2 切分然后计算 RMSE 和 TopN 推荐的命中率。RMSE 反映评分预测的误差命中率反映用户实际喜欢的物品是否出现在推荐列表里。这两个指标任何一个不合格都要回头调算法。在线评估更简单可靠看推荐位点击率。我在推荐接口里埋了点击日志每三天统计一次推荐点击率。正常 IP 化的旅游推荐系统点击率在 5%~15% 之间。如果点击率长期低于 5%不是算法出了问题就是推荐列表被热榜支配了需要检查冷启动兜底策略提供的热度结果占比是否太高建议把热榜比例降到 30% 以下。参数调优方面我重点调三个值协同过滤的邻居数量 K推荐 20~50、最小共同评分数量推荐 3~5、相似度阈值推荐 0.3~0.4。这些参数没有绝对标准我在项目里用脚本遍历参数组合以离线指标为基准选最优配置。比如把邻居数量从 10 调整到 30命中率从 14% 升到了 21%但继续增加到 50 又降到 19%所以 30 左右是当前数据量的有效范围。5.3 Ubuntu / Debian 服务器部署实操开发完成后面临部署这里分享一套我实测可行的部署路线基于 Ubuntu/Debian 系 Linux 发行版。推荐生产环境搭配Gunicorn Nginx Redis PostgreSQL这套组合在目前技术社区中非常成熟国内云服务器也能轻松压住。sudo apt update sudo apt install python3-venv python3-pip redis-server nginx postgresql cd /opt/travel_sys python3 -m venv venv source venv/bin/activate pip install -r requirements.txt python manage.py migrate python manage.py collectstatic --noinputGunicorn 负责运行 Django 进程Nginx 负责静态文件与反向代理。重点是/etc/systemd/system/travel.service这个 service 文件日志输出和进程守护交给 systemd 管理[Unit] Descriptiontravel system Afternetwork.target [Service] Userwww-data WorkingDirectory/opt/travel_sys ExecStart/opt/travel_sys/venv/bin/gunicorn travel_sys.wsgi:application -b 127.0.0.1:8000 Restartalways EnvironmentDJANGO_SETTINGS_MODULEtravel_sys.settings.prod [Install] WantedBymulti-user.target部署时最容易被忽略的是 Debug 开关和静态文件收集。生产环境务必将DEBUGFalse并设置ALLOWED_HOSTS否则会暴露大量调试信息和安全隐患。Nginx 里配置好静态文件 alias 指向STATIC_ROOT再把 API 请求反代到127.0.0.1:8000即可。5.4 源码结构与后续扩展思路源码目录组织如下建议按顺序阅读travel_sys/ ├── analytics/ # 数据分析模块 │ ├── views.py # 统计视图 │ └── services.py # pandas 统计服务 ├── reviews/ # 评价模块 │ ├── models.py # Rating, Review │ └── views.py # 评价接口 ├── recommender/ # 推荐引擎模块 │ ├── algorithms/ # user_cf.py, item_cf.py │ ├── services.py # 推荐调度与混合策略 │ └── cache.py # 缓存读写 ├── travel_sys/ # 项目配置 │ ├── settings.py │ └── urls.py ├── scripts/ │ └── offline_update.py # 离线推荐计算脚本 └── static/ # 前端资源阅读路径建议先看reviews/models.py了解数据结构再看recommender/services.py理解推荐调度流程接着看recommender/algorithms/item_cf.py掌握核心算法最后看scripts/offline_update.py弄懂定时计算任务。后续扩展可以做三个方向。第一是引入基于内容的推荐用景点的描述文本做 TF-IDF 或词向量相似度解决新景点冷启动第二是接入真实游客行为日志埋点用点击流数据替换简单评分做隐式反馈第三是把实时推荐接口改造成异步任务用 Celery 处理推荐计算前端秒开、后端不影响。这套系统虽然是旅游场景但架构和算法完全可以迁移到电影、美食、酒店推荐项目。我把整个项目完整源码已经整理放出来了拿到后先跑通requirements.txt再对照本文阅读。最后分享一点个人体会做推荐系统最重要的不是算法炫技而是把数据闭环做完整。用户产生行为、行为进入模型、模型生成推荐、推荐再收到反馈这个环只要有一环断裂推荐效果一定差。我在这个项目上把大量时间花在数据清洗和缓存设计上而不是一味调算法参数最终效果反而比预期好很多。如果你也在做类似项目希望这篇记录能帮你少走一些我已经走过的弯路。