去年底我接手了一个面向C端用户的旅游内容产品产品经理给的诉求很简单用户打开App就知道去哪里玩并且越用越懂他。当时团队内部有现成的Java技术栈但我权衡之后还是选了PythonDjangoVue.js这套组合来做旅游推荐系统前后端分离两周内跑通了第一版可演示的Demo。说实话旅游推荐系统这个题不算难但要把推荐、搜索、用户行为、前端交互串成一条完整链路里面的坑比想象中多。这篇文章就把我实际做下来的核心环节拆开讲从数据建模到算法落地从前端联调到部署上线适合准备做旅游类Web应用或想了解DjangoVue全栈实战的人参考。1. 需求梳理与技术选型先想清楚再做决定1.1 旅游推荐系统的核心需求拆解动手写代码之前我先把业务场景捋了一遍。旅游推荐系统和传统的内容管理系统不一样它至少要接住三类需求第一是游客找景点也就是能按城市、关键词、标签、价格和评分去筛选第二是游客产生行为之后系统要能根据浏览、收藏、停留时长等动作猜测他接下来对什么感兴趣第三是运营端得有一个方便录入景点、维护标签、配置推荐位后台。三者缺一不可否则做出来只是一个景点展示站。围绕这三类需求我画出六个核心模块用户认证与个人中心、景点管理与检索、基于行为数据的个性化推荐、收藏与评论、行程路线规划、后台运营配置。第一版我没有强行做行程规划因为算法和数据结构复杂度会明显上升先把推荐主流程跑通更重要。模块确定后技术选型就顺理成章了后端需要快速支撑数据模型变更和后台管理Django的ORM和自带Admin是效率利器前端需要做动态交互、路由切换和状态管理Vue.js的组件化天然适合这种场景。1.2 为什么选 Django Vue.js 这套组合很多团队遇到这种推荐类项目第一反应是上Spring Boot微服务或者干脆用Flask写接口再加个静态页面。我的判断不一样。旅游推荐系统的瓶颈从来不在并发而在“业务迭代速度”和“数据模型调整频率”。推荐策略可能要换四五种景点字段隔几天加一个评分维度如果后端用灵活性不高的方案每次改模型都要动一堆胶水代码项目很容易被拖垮。Django在这方面的优势非常明显自带ORM迁移工具字段调整一条命令就能同步到数据库自带Admin后台运营人员录景点、配标签无需额外开发管理界面。配合Django REST framework写API分页、过滤、认证都是现成组件。前端Vue.js则让我把复杂的页面状态隔离在组件内部推荐流只需要维护一个列表状态筛选条件变了重新请求接口组件内部响应式更新即可。默认盒子模式Django的模板引擎也能用但既然要做前后端分离、方便后续接App和小程序Vue.js是更合适的长期选择。选型确定后我做的第一件事是把项目结构分成backend和frontend两个目录。backend用Django 4.2frontend用Vue 3 Vite。开发环境里前端3000端口后端8000端口通过Vite的devServer代理把/api前缀转发到Django这样联调阶段不需要处理跨域CORS等部署时再用Nginx统一接管。这套组合的另外一个隐性收益是团队成员换人接手时Django和Vue的社区资料足够多不会出现看不懂的私有框架。2. 数据模型设计旅游推荐系统的地基2.1 从景点到用户行为的五张核心表数据模型是推荐系统的地基我在这里花的时间比写API还多。第一版我设计了五张核心表用户表、区域表、景点表、标签表、用户行为表另外加上收藏和评论两张辅助表。用户表直接继承Django自带的AbstractUser扩展手机号和头像字段区域表记录省、市、区三级层级方便按城市兜底推荐景点表是核心业务表字段包括名称、区域外键、摘要、详细描述、封面图、经纬度、开放时间、门票参考价、综合评分、浏览量、收藏量。用户行为表是整个推荐系统最关键的账本。它不直接存“用户喜欢什么”而是记录每一次行为事件谁、在什么时间、对哪个景点、产生了浏览还是收藏动作。如果没有这张表后续所有个性化推荐都是空中楼阁。Django模型代码大致是class UserBehavior(models.Model): class BehaviorType(models.TextChoices): VIEW view, 浏览 FAVORITE favorite, 收藏 SEARCH search, 搜索 user models.ForeignKey( settings.AUTH_USER_MODEL, on_deletemodels.CASCADE, related_namebehaviors ) attraction models.ForeignKey( Attraction, on_deletemodels.CASCADE, nullTrue, blankTrue ) behavior_type models.CharField( max_length16, choicesBehaviorType.choices ) created_at models.DateTimeField(auto_now_addTrue, db_indexTrue) class Meta: ordering [-created_at]行为表一定要给user和behavior_type建联合索引否则用户量稍涨按用户查历史行为时会变成全表扫描。另一个细节是不要为了省事把所有字段都堆在单张表里喜欢“大宽表”的后果往往是后期索引膨胀、序列化慢推荐接口一压测就崩。2.2 标签体系与多对多关系内容推荐的关键景点和标签的关系是典型的多对多。一个景点可以同时被打上“亲子”“温泉”“古镇”“适合拍照”等标签一个标签也对应多个景点。Django里用ManyToManyField表达最直接生成中间表由ORM自动维护。标签体系是用来做内容推荐和兜底推荐的如果标签质量差推荐模型再漂亮也白搭。在运营后台我给每个景点配置标签时要求标签粒度保持一致性。比如“亲子”和“适合带孩子去”这类近义标签不能同时存在否则余弦相似度计算时会被拆成两个没有交集的维度。实际项目中我直接限制了标签词库由运营维护新建景点必须从词库里选不允许自由输入从源头杜绝标签乱象。用户侧同样让用户选择兴趣标签来源词库与景点标签保持一致这样用户向量和景点向量就能直接做相似度匹配。除了标签我还设计了热度分数字段冗余存储在景点表里而不是每次请求实时计算。实时计算看起来很酷但在QPS上去之后会让数据库反复聚合性能极差。我会在每次行为事件发生时通过异步任务累加热度分推荐列表直接按这个字段排序逻辑简单而且响应快。关于热度分的计算方式下一节展开说。3. 推荐算法落地从冷启动到个性化3.1 冷启动热度与编辑精选兜底推荐系统最怕冷启动新用户没有任何行为记录老景点没有足够反馈算法模型无法生效。这时候只能用热度推荐和编辑精选来兜底。我实现的热度分不是单纯的浏览量累加而是综合了浏览、收藏、评分三个维度并加入时间衰减因子避免老景点永远压过新景点。热度分的计算公式大概是hot_score view_count * 0.3 favorite_count * 2 score * 100 time_decay 1 / (1 0.1 * days_since_created) final_score hot_score * time_decay这个公式是我根据业务经验调的收藏的权重远高于浏览因为收藏代表更明确的意愿评分乘以100是为了平衡量纲让少量高评分景点有机会出现在首页。实际效果中时间衰减系数0.1会让三个月前的景点热度下降约10倍三个月以上的内容逐渐沉淀新景点更容易浮上来。运营端额外维护了一张编辑精选表人工配置3到8个精品景点置顶冷启动首页第一屏就是精选与热度混合。3.2 基于内容的推荐实现冷启动解决后要做个性化推荐。我优先实现的是基于内容的推荐也叫Content-based Recommendation。它的核心思路是把景点描述转成特征向量然后把用户的行为历史拼成一个用户特征向量最后计算用户向量和候选景点向量的余弦相似度取Top K返回。具体实现起来分三步。第一步用中文分词库把景点详情切词再用TF-IDF把切词结果转成向量。第二步从用户行为表里捞用户最近浏览过的景点向量逐条按时间加权累加形成用户画像向量。第三步遍历候选景点计算余弦相似度按相似度降序取Top K。代码如下from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.metrics.pairwise import cosine_similarity import jieba def build_attraction_vectors(attractions): documents [] for item in attractions: words jieba.lcut(item.summary item.description) documents.append( .join(words)) vectorizer TfidfVectorizer(token_patternr\b\w\b, max_features3000) matrix vectorizer.fit_transform(documents) return matrix, vectorizer这里有个容易踩的坑TF-IDF的词表必须在全量景点上训练不能只拿当前用户看过的景点训练。否则不同用户向量的维度空间不一致余弦相似度会失真。所以我把向量构建放到了离线任务里每天凌晨跑一次全量更新景点向量矩阵线上接口只负责加载内存缓存。3.3 协同过滤的简化版本基于内容的推荐有个先天问题它永远推荐和用户看过的内容相似的东西缺少惊喜度。协同过滤能补上这个短板思路是“和你行为相似的人也可能喜欢你没看过的景点”。我实现了最简单的物品协同过滤Item CF核心逻辑是统计任意两个景点在同一用户的收藏列表里共同出现的次数次数越高景点间相似度越高推荐时找到用户收藏过的景点再找到这些景点的相似景点过滤掉已经看过的按相似度排序返回。第一版我没有引入现成的推荐框架直接用Pandas和Python字典实现。因为数据量在万级共现矩阵稀疏但不算太大内存完全扛得住。计算时做一层过滤只统计行为质量高的收藏行为浏览行为不做协同素材否则噪音会让推荐变得很奇怪。等用户行为数据超过百万级之后这种朴素实现就不够用了那时候再考虑引入Spark或图数据库但小项目别做技术炫耀简单方案能跑通就是胜利。3.4 混合推荐与接口聚合只调一个算法容易让推荐结果越来越窄我最后采用混合策略个性化推荐和热门推荐按一定比例糅合。接口返回时给每个推荐项标记来源是“猜你喜欢”“热门推荐”还是“编辑精选”前端拿到后可以展示不同角标用户心理感受也更丰富。推荐接口的大致逻辑是用户登录了且有足够行为记录就取内容推荐Top 5、Item CF Top 3、热门Top 2按规则去重后组合返回用户行为不够就取热门Top 5加编辑精选Top 3游客直接取热门Top 10。这个策略我用了一个配置开关什么比例更合理可以通过运营后台动态调整而不是每次改功能都要发版。混合推荐的接口聚合逻辑放在服务层不在视图函数里写一大坨方便单元测试和后续替换策略。4. 后端API与权限设计Django REST framework 的正确打开方式4.1 JWT认证与用户体系旅游推荐系统涉及登录态API不能再裸奔。我采用JWT认证选的是djangorestframework-simplejwt这个库配置不多但有几个关键点必须注意。一是用户模型要尽量在第一次迁移前就换成自定义模型避免后续改AUTH_USER_MODEL带来一堆迁移问题。第二SimpleJWT默认只返回access和refresh两个token但前端往往还需要用户ID、昵称、头像所以需要自定义一个token序列化器在登录接口里一并返回用户信息。第三access token有效期建议设为1小时左右refresh token设为7天太短影响体验太长不安全。配置大致如下SIMPLE_JWT { ACCESS_TOKEN_LIFETIME: timedelta(hours1), REFRESH_TOKEN_LIFETIME: timedelta(days7), AUTH_HEADER_TYPES: (Bearer,), }在DRF全局配置里默认认证类指向JWTAuthentication默认权限类设为IsAuthenticatedOrReadOnly。旅游类接口的特点是大部分查询允许游客访问但收藏、评论、修改个人资料必须登录。全局设置IsAuthenticatedOrReadOnly之后再在具体视图上用permission_classes覆盖即可少写很多重复判断。4.2 核心API列表与分页筛选第一版只保留满足业务主流程的API避免接口膨胀。认证、景点、推荐、行为、收藏、评论加在一起刚好10个左右每个接口都用DRF的ViewSet或APIView统一处理。我整理的接口列表如下功能模块接口路径方法说明认证/api/auth/registerPOST注册认证/api/auth/loginPOST登录返回JWT景点/api/attractionsGET列表支持搜索/筛选/分页景点/api/attractions/{id}GET详情推荐/api/recommendationsGET按用户返回推荐结果行为/api/behaviorsPOST上报浏览/收藏行为收藏/api/favoritesGET/POST/DELETE收藏列表与增删评论/api/attractions/{id}/commentsGET/POST评论查询与发布景点列表筛选全部走DRF的django-filter字段包括区域、标签、价格区间、评分排序、热度排序。分页用PageNumberPagination每页默认10条。这里有一个经验排序字段不要用字符串拼接DRF的ordering_filter就能安全处理既满足业务需求又避免SQL注入风险。4.3 用缓存扛住推荐接口的并发推荐接口往往是系统里最耗时的接口尤其是基于内容的推荐需要做矩阵运算和排序。即便我已经把向量矩阵加载到内存每次请求仍然要做大量的点积和排序。为了降低重复计算成本我给推荐结果加了Redis缓存。缓存key的设计要区分用户维度匿名用户缓存“hot:top:20”登录用户缓存“recommend:{user_id}:{page}”缓存时间设置成10分钟。用户产生新的收藏行为时不立即删除缓存而是让缓存自然过期避免瞬间回源把数据库击穿。景点详情这种高频读接口同样加一层Redis缓存key变成“attraction:{id}”在运营后台修改景点时主动删除对应缓存保证数据一致。实际压测时不加缓存推荐接口平均响应在180ms左右加缓存后降到20ms以内。这个提升相当可观而且Redis在内网部署应用和缓存之间的网络延迟可以忽略不计。对旅游推荐系统这个量级完全够用。5. 前端实现与前后端联调Vue 三件套就够了5.1 前端工程结构与启动脚手架前端我没有用Nuxt这类偏重的框架就是标准Vue 3 Vite Pinia Vue Router Element Plus。Vite创建项目快冷启动编译快配合Element Plus组件库页面交互开发效率很高。目录上我分成views、components、api、store、router、utils这几个模块views对应路由级页面components只放可复用组件。api目录里我用axios封装了一个统一请求实例拦截器负责自动往请求头加Authorization。token从Pinia和localStorage双读登录成功后写入退出登录时清空。响应拦截器统一处理后端返回的code和message如果碰到401说明token失效自动跳转到登录页并且把之前访问的路由存下来登录后跳回去这个细节对体验提升明显。5.2 推荐页与详情页从Axios到状态管理推荐首页是整个系统最核心的页面。我在Pinia里维护一个推荐列表state首次进入时请求/api/recommendations获取数据点击筛选标签时切换参数重新请求。为了提高浏览体验推荐页做成卡片流每张卡片展示景点封面、名称、评分、标签和一句话推荐理由滑动到底部触发翻页加载。景点详情页承担两个职责展示信息和采集行为。信息展示包括图片列表、区域、评分、门票、开放时间、详细介绍另外还有一个点赞收藏按钮。详情页挂载时前端自动调用行为上报接口记录一次浏览行为用户点击收藏时调用收藏接口并提示成功。行为上报放在详情页而不是列表页是因为进入详情代表用户兴趣更明确对推荐算法的训练更有价值。前后端联调阶段最大的坑是CORS。虽然开发环境我用了Vite代理但只要直接把前端请求地址写死成后端IP浏览器就会因为跨域被拦截。Django这边需要装django-cors-headers在settings里配置允许的来源。生产环境我更推荐用Nginx反代让前端和后端看起来同源这样能少处理一整套跨域问题cookie和认证也更省心。5.3 列表筛选与搜索交互别让接口背锅搜索交互上我遇到一个典型的体验问题用户每敲一个字就触发搜索接口后端被无效请求打满前端页面还频繁刷新。最后我采用防抖加路由联动搜索输入停止500ms后才发请求带词和筛选条件一并放到URL查询参数里这样用户刷新页面后筛选条件不丢失也能直接分享搜索结果链接。景点列表页的筛选器包含城市、标签、排序方式三个核心维度。城市和标签来源都由对应API下发不在前端硬编码。排序方式只用热度、评分、价格三种多一个选项都会增加后端索引压力。实测下来防抖加上缓存之后列表页的请求量减少了六成用户体验反而更稳定因为不会再出现快速输入时结果乱跳的问题。6. 部署上线与性能优化别让项目停在本地6.1 Nginx Gunicorn 部署前后端开发联调完成后我直接把项目部署到一台2核4G的云服务器上。后端用Gunicorn启动Django绑定8000端口4个worker进程前端构建成静态文件后由Nginx托管Nginx同时负责反向代理/api到Gunicorn。Nginx的核心配置如下server { listen 80; server_name your_domain; location / { root /var/www/frontend; # 前端静态文件目录 index index.html; 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; } }配置里最容易出问题的是try_files。Vue Router如果用history模式刷新一个二级路由时Nginx会把请求发到后端但后端根本没有这个路由就会返回404。try_files的最后一个参数/index.html就是用来接管这类请求让前端路由自己处理。如果不想要这个方案也可以改成hash模式但URL上会多一个#号我宁愿用try_files保持URL干净。6.2 N1查询与序列化器优化部署上线后我做了一轮接口排查发现景点列表接口慢得离谱。打开Django Debug Toolbar一看一个列表请求居然发了30多遍SQL。原因是序列化器里嵌套了区域名、标签列表Django ORM默认是惰性加载循环到每条记录时才查一次关联表。这就是典型的N1查询问题。解决办法很简单视图集里加上select_related和prefetch_related。select_related负责解决外键关联比如区域prefetch_related负责解决多对多关联比如标签。改完之后的ORM查询变成两三条SQL列表接口响应时间从900ms降到40ms。这个优化虽然基础但效果立竿见影任何带嵌套序列化的接口都要养成习惯。6.3 行为日志异步落库与监控行为上报接口在推荐链路里会被高频调用如果每一条都同步写数据库高峰期会出现锁等待。我最终用Celery加Redis做了异步任务Django接收到行为上报请求后先把数据推入Redis列表Celery worker定时拉取并批量写入数据库热点景点计数则直接使用Redis自增。异步化之后行为接口响应时间基本等于Redis写一次的时间3到5毫秒用户体验几乎无感知。监控方面没有上重系统我只设置了基础日志和报警Django日志按天滚动记录接口耗时和异常Redis缓存命中率每周看一次命中率低于80%就检查缓存key是否频繁失效。这套轻量监控对个人项目和小团队已经足够不建议在一开始就引入链路追踪之类的重型方案。7. 回过头来复盘这套方案的实战边界与可扩展方向项目上线运行一个月后我重新审视当初的技术决策。Django Vue.js这套组合给我的最大感受是“开发效率高故障点少”。Django的ORM和Admin帮我省掉大量重复的CRUD代码Vue.js的组件化让前端需求变更成本可控推荐算法则依赖清晰的数据模型稳稳落地。对于旅游推荐系统这种业务模型相对稳定、算法策略需要快速迭代的项目这套方案是最务实的搭配。但我也清楚它的边界。如果用户量突然涨到百万级行为数据冲到亿级基于Python字典的协同过滤就会先崩溃那时需要引入离线计算框架比如Spark或者更专业的推荐引擎。同样如果产品形态变成重社交的旅游社区Django默认的同步模型也会成为瓶颈得把消息推送、动态流拆成独立服务。后续再迭代我计划做三件事一是把用户画像单独建表定期离线更新让推荐接口只读画像不再实时聚合行为二是在景点详情里接入实时客流数据把“现在去人少”作为新的推荐因子三是把行程规划补上根据用户选出的几个景点调用路线规划API生成一日游方案。这些方向都和现有架构兼容不需要推翻重来。做项目就是这样没必要一步到位先把核心闭环跑通再根据真实数据决策下一步比一开始就设计一套“完美系统”靠谱得多。
阅读完成 · 觉得有帮助?