ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于Django+Flask+Vue的粤畅游旅游推荐系统全栈开发实践

基于Django+Flask+Vue的粤畅游旅游推荐系统全栈开发实践 做了一个“粤畅游”旅游推荐系统技术栈选了Python后端Vue前端后端同时用到Django和Flask开发环境是PyCharm。这套组合做下来基本把Python全栈开发的主流程都走了一遍数据建模、接口设计、推荐逻辑、前后端联调、打包部署。这篇文章把整个项目的思路、实现细节、踩坑点完整记录下来给正在做类似旅游推荐系统、或者想用DjangoFlaskVue做毕设/练手项目的朋友一个能直接照搬的参考。先说明一下我的方案定位Django负责主体业务和核心APIFlask负责独立的推荐服务。这个分工不是拍脑袋定的后面我会详细解释原因。整个系统实现的内容包括景点展示、城市筛选、类型标签筛选、热门推荐、个性化推荐、景点详情、收藏功能、路线推荐以及后台数据管理基本涵盖了一个旅游类小程序/Web端常见的功能闭环。1. 技术选型与系统架构1.1 为什么同时用Django和Flask很多人在刚接触这套技术栈的时候会纠结到底选Django还是Flask我的结论是如果项目够复杂两个一起用并不冲突甚至更合理。Django的强项是全家桶自带ORM、Admin后台、认证体系、表单校验做一个需要管理后台的业务系统非常省事。Flask的强项是轻、灵活适合做单一职责的服务。在这个项目里Django承担的是主业务后端景点数据管理、用户注册登录、收藏和评论功能、提供前端展示所用的全部REST接口。Flask则单独跑一个推荐引擎服务接收用户行为数据计算推荐结果再通过HTTP接口把结果抛给Django层。为什么要拆出来因为推荐算法后续可能会迭代比如从简单热度榜升级成协同过滤甚至是基于Embedding的向量召回用Flask隔离出来之后改动推荐逻辑不影响主站业务的稳定。实际上你也会发现Django跑推荐也不是不行但每次改推荐代码都要过一遍Django工程的中间件、路由、配置试错成本高。Flask服务几十行代码就能起一个接口部署的时候单独挂端口主站挂了推荐接口依然可以降级返回热门榜单可用性上更灵活。1.2 Django和Flask通信的方式两个后端之间直接通过HTTP调用Django的View里用requests库调Flask服务这种方式最直观也最容易排查问题。Django侧的核心调用逻辑大概是这样的import requests from django.conf import settings def get_recommendations(user_id, cityNone, limit10): flask_url f{settings.FLASK_SERVICE_URL}/api/recommend params {user_id: user_id, city: city or , limit: limit} try: resp requests.get(flask_url, paramsparams, timeout3) if resp.status_code 200: return resp.json().get(items, []) except requests.exceptions.Timeout: return [] return []Flask侧对应的推荐接口就是一个标准的JSON返回把推荐结果包装成统一结构。from flask import Flask, request, jsonify import redis app Flask(__name__) cache redis.Redis(host127.0.0.1, port6379, db1) app.route(/api/recommend, methods[GET]) def recommend(): user_id request.args.get(user_id, typeint, default0) city request.args.get(city, default) limit request.args.get(limit, typeint, default10) # 先从缓存取推荐结果取不到再实时计算 cache_key frec:{user_id}:{city}:{limit} cached cache.get(cache_key) if cached: return jsonify({items: eval(cached), source: cache}) items compute_recommendations(user_id, city, limit) cache.setex(cache_key, 300, str(items)) return jsonify({items: items, source: realtime})这里加了一个Redis缓存推荐结果缓存5分钟极大降低了推荐计算对数据库的压力。注意eval只在内部可信场景用生产环境建议换成json.loads。1.3 前端框架选型和目录结构前端用了Vue 3 Vue Router Pinia Element Plus。选择Vue而不是传统模板渲染核心原因在于这个系统的交互复杂度景点列表要实时筛选、推荐结果要异步刷新、用户收藏要即时反馈用Vue组件化开发可以把这些状态管理得清清楚楚。推荐的分层架构是Vue页面组件调用Pinia StoreStore里封装统一的Axios请求方法API层单独抽离一个目录。目录结构如下travel-frontend/ ├── src/ │ ├── api/ │ │ ├── attractions.js # 景点相关接口 │ │ ├── recommend.js # 推荐相关接口 │ │ └── user.js # 用户相关接口 │ ├── assets/ │ ├── components/ │ │ ├── AttractionCard.vue # 景点卡片组件 │ │ ├── CityFilter.vue # 城市筛选栏 │ │ └── RatingStars.vue # 评分星星组件 │ ├── router/ │ │ └── index.js │ ├── stores/ │ │ ├── userStore.js │ │ └── attractionStore.js │ ├── views/ │ │ ├── HomeView.vue │ │ ├── ListView.vue │ │ ├── DetailView.vue │ │ └── RecommendView.vue │ ├── utils/ │ │ └── request.js # Axios统一封装 │ ├── App.vue │ └── main.js └── package.json后端Django工程结构相对标准travel_backend/ ├── manage.py ├── config/ │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── apps/ │ ├── attractions/ # 景点模块 │ ├── users/ # 用户模块 │ ├── orders/ # 收藏/评论模块 │ └── recommend/ # 推荐相关的Django层调用Flask ├── static/ └── templates/2. 数据库设计与推荐逻辑实现2.1 核心数据表设计一个旅游推荐系统的数据模型核心是景点表、用户表、收藏表、评论表、用户行为表。下面给出最关键的几个表结构用的是Django模型定义。# apps/attractions/models.py from django.db import models class Attraction(models.Model): name models.CharField(max_length128, verbose_name景点名称) city models.CharField(max_length32, db_indexTrue, verbose_name城市) district models.CharField(max_length64, blankTrue, verbose_name所在区域) category models.CharField(max_length32, db_indexTrue, verbose_name类型) cover_url models.URLField(verbose_name封面图) images models.TextField(blankTrue, verbose_name图集JSON) price models.DecimalField(max_digits8, decimal_places2, default0, verbose_name门票价格) rating models.FloatField(default0, verbose_name评分) rating_count models.IntegerField(default0, verbose_name评分数) popularity models.FloatField(default0, verbose_name热度值) tags models.CharField(max_length255, blankTrue, verbose_name标签) keywords models.TextField(blankTrue, verbose_name搜索关键词) intro models.TextField(verbose_name简介) lat models.FloatField(default0, verbose_name纬度) lng models.FloatField(default0, verbose_name经度) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table attractions ordering [-popularity]这里我要重点说明几个字段设计的考虑首先city和category都设置了db_indexTrue因为列表页最常见的查询就是按城市、按类型筛选。如果不加索引数据量上了几万条之后筛选接口会明显变慢。其次images字段直接存JSON字符串而不是单独建一张图片表原因是景点图片只是展示用途不需要跟业务表做关联查询JSON字符串读取方便也省去联表操作。类似的做法在项目初期完全够用后续如果需要做图片审核、标签化管理再拆表。用户表用Django自带的User扩展一个Profile表存偏好信息。# apps/users/models.py from django.db import models from django.contrib.auth.models import User class UserProfile(models.Model): user models.OneToOneField(User, on_deletemodels.CASCADE, related_nameprofile) preferred_city models.CharField(max_length32, blankTrue, verbose_name常去城市) visit_days models.IntegerField(default1, verbose_name预计游玩天数) budget models.IntegerField(default500, verbose_name预算上限) interests models.TextField(blankTrue, verbose_name兴趣标签逗号分隔)收藏和用户行为表是推荐算法的核心数据来源。# apps/orders/models.py class Favorite(models.Model): user models.ForeignKey(User, on_deletemodels.CASCADE, related_namefavorites) attraction models.ForeignKey(attractions.Attraction, on_deletemodels.CASCADE, related_namefavorited_by) created_at models.DateTimeField(auto_now_addTrue) class Meta: db_table favorites unique_together (user, attraction) class UserAction(models.Model): user models.ForeignKey(User, nullTrue, blankTrue, on_deletemodels.SET_NULL) attraction models.ForeignKey(attractions.Attraction, on_deletemodels.CASCADE) action_type models.CharField(max_length16, db_indexTrue) # view / fav / rating / search score models.FloatField(default1.0) timestamp models.DateTimeField(auto_now_addTrue)UserAction这张表是整个推荐系统的数据基础每次用户浏览、收藏、评分前端都会调用接口记录一条行为。刚开始可能觉得多余但等到要做个性化推荐的时候你会发现没有行为数据寸步难行。2.2 推荐算法的三个层次这个系统的推荐算法我分了三层实现每一层都有明确的降级策略。第一层热门榜推荐热门榜是最基础、永远兜底的推荐策略。热度值不直接等于浏览量而是加权计算。hot_score 浏览量 * 0.4 收藏量 * 0.3 评分人数 * 0.1 平均评分 * 2.0这个公式里的系数是经验值可以根据实际运营数据调节。实现上可以在每次用户浏览、收藏时触发热度值更新也可以定时用脚本重算。def update_popularity(attraction_id): attraction Attraction.objects.get(pkattraction_id) views UserAction.objects.filter(attraction_idattraction_id, action_typeview).count() favs Favorite.objects.filter(attraction_idattraction_id).count() ratings UserAction.objects.filter(attraction_idattraction_id, action_typerating).count() avg_rating attraction.rating if attraction.rating_count else 0 popularity views * 0.4 favs * 0.3 ratings * 0.1 avg_rating * 2.0 Attraction.objects.filter(pkattraction_id).update(popularitypopularity)第二层基于标签匹配的推荐当用户注册时选择了兴趣标签比如“历史文化”“自然风光”“亲子乐园”系统根据这些标签去匹配景点。具体的匹配逻辑是把用户的兴趣标签和景点Tags做交集计算对匹配度高的景点加权排序。代码实现如下def recommend_by_tags(user_profile, limit10): if not user_profile or not user_profile.interests: return [] interest_tags [t.strip() for t in user_profile.interests.split(,) if t.strip()] attractions Attraction.objects.all() scored [] for att in attractions: att_tags [t.strip() for t in att.tags.split(,) if t.strip()] match_count len(set(interest_tags) set(att_tags)) if match_count 0: score match_count * 2 att.popularity scored.append((score, att)) scored.sort(keylambda x: x[0], reverseTrue) return [att for _, att in scored[:limit]]这个写法虽然简单但有一个性能隐患每次都要全表扫描。景点数据量在几千条以内问题不大如果以后扩展到几十万条必须改成数据库侧匹配或者用倒排索引这个要注意。第三层基于用户行为的协同过滤协同过滤是推荐系统的经典算法。我在这版系统里实现了基于物品的协同过滤ItemCF逻辑是找出用户最近浏览/收藏过的景点找到其他也浏览过这些景点的用户再找到这些用户浏览过的其他景点按共现次数排序推荐。核心的计算步骤可以讲清楚便于面试或者答辩时解释原理。# Flask服务的实现 def item_cf(user_id, limit10): # 1. 获取该用户最近的交互物品 user_attractions get_user_recent_actions(user_id, top_n10) sim_scores {} # 2. 遍历每个物品找出喜欢该物品的用户 for att_id in user_attractions: similar_users get_users_actions_by_attraction(att_id) # 3. 遍历这些用户找出他们喜欢的其他物品 for uid in similar_users: other_attractions get_user_recent_actions(uid, top_n10) for other_id in other_attractions: if other_id att_id: continue sim_scores[other_id] sim_scores.get(other_id, 0) 1 sorted_items sorted(sim_scores.items(), keylambda x: x[1], reverseTrue) return [item_id for item_id, _ in sorted_items[:limit]]这个版本的逻辑很原始没有做归一化、没有惩罚热门物品但作为初学者理解和实现是足够的。后续优化方向可以是引入Jaccard相似度、对热门物品降权加惩罚项1/log(1 popularity)、加入时间衰减因子等。2.3 Django ORM操作要点开发过程中Django ORM有几个细节要特别记住。删除对象用的是.delete()方法这个方法有坑如果模型定义了on_deletemodels.CASCADE删一个对象会级联删除关联数据。比如删除一个景点所有关联的收藏和用户行为都会一并删除。如果只想删景点的推荐状态而不想删行为记录一定要先清理关联。# 正确删除单个对象 attraction Attraction.objects.get(pk1) attraction.delete() # 批量删除要小心返回的是元组 (删除总数, 各表删除数明细) deleted_count, details Attraction.objects.filter(city广州).delete()另外Django查询结果默认是惰性的filter()并不会真的执行数据库查询只有遍历或者调用list()、len()等操作时才触达数据库。调试时要区分QuerySet和真正的数据列表# QuerySet是惰性的不是列表 queryset Attraction.objects.filter(city广州) print(type(queryset)) # class django.db.models.query.QuerySet # 如果需要转换成列表用 list() attractions list(queryset)3. Vue前端开发实战3.1 开发环境搭建与工程初始化前端开发环境我用的组合是Node.js 18 LTS Vue CLI或者Vite Vue 3。这里要提醒新人直接使用npm create vuelatest初始化项目比手动配置Webpack省心得多。如果用Vite创建项目执行这几步npm create vitelatest travel-frontend -- --template vue cd travel-frontend npm install npm install vue-router4 pinia axios element-plus npm run devElement Plus的引入有两种方式完整引入和按需引入。新手建议先用完整引入省去配置插件的麻烦后续优化性能再考虑按需// main.js import { createApp } from vue import { createPinia } from pinia import ElementPlus from element-plus import element-plus/dist/index.css import App from ./App.vue import router from ./router const app createApp(App) app.use(createPinia()) app.use(router) app.use(ElementPlus) app.mount(#app)关于Axios封装我要强调一个重点一定要统一处理BaseURL、请求超时、错误提示和Token注入不要在每一个页面组件里直接axios.get。封装好之后每个组件里调用会清爽很多。// utils/request.js import axios from axios import { ElMessage } from element-plus const request axios.create({ baseURL: /api, timeout: 8000 }) // 请求拦截器加Token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一错误提示 request.interceptors.response.use( response response.data, error { ElMessage.error(error.response?.data?.message || 网络异常) return Promise.reject(error) } ) export default request统一封装的好处是后期如果要改鉴权方案、加日志上报、处理Token过期自动刷新只需要动一个文件所有页面全部生效。3.2 路由与页面组件设计路由设计上这个系统的前端有四个核心页面首页推荐、景点列表、景点详情、个人中心。// router/index.js import { createRouter, createWebHistory } from vue-router const router createRouter({ history: createWebHistory(), routes: [ { path: /, component: () import(../views/HomeView.vue) }, { path: /attractions, component: () import(../views/ListView.vue) }, { path: /attraction/:id, component: () import(../views/DetailView.vue) }, { path: /recommend, component: () import(../views/RecommendView.vue) }, { path: /user, component: () import(../views/UserView.vue) } ] })路由的懒加载用了动态import这样首屏不会一次性把所有页面都下载下来加载速度会快很多。在景点列表页核心是筛选条件与请求参数的联动。当用户选择城市、类型、排序方式时组件内部通过watch监听变化触发数据重新获取。template div classlist-page CityFilter :city.syncquery.city / CategoryFilter :category.syncquery.category / SortBar :sort.syncquery.sort / div v-loadingloading classattraction-grid AttractionCard v-foritem in list :keyitem.id :dataitem clickgoDetail(item.id) / /div el-pagination v-model:current-pagequery.page :page-sizequery.pageSize :totaltotal layoutprev, pager, next / /div /template一个很容易踩的坑v-for的:key不要用索引下标必须用唯一ID。因为列表渲染后如果数据顺序变了Vue靠key做节点复用用index会导致详情页打开错误的数据。3.3 接口调用与数据渲染景点详情页的接口调用需要重点处理两个问题URL参数获取和页面加载态。script setup import { ref, onMounted } from vue import { useRoute } from vue-router import { getAttractionDetail, recordView } from ../api/attractions const route useRoute() const detail ref(null) const loading ref(true) onMounted(async () { const id route.params.id try { detail.value await getAttractionDetail(id) // 浏览行为记录用于推荐算法 recordView(id) } finally { loading.value false } }) /script这里有个细节recordView是异步操作但不需要等待结果收藏和浏览记录可以参考埋点的思路做到“不影响主流程”。如果后期并发大了可以对行为上报做批量合并前端攒一批数据然后再发一个接口减少请求次数这个后续可以优化。推荐页面的话后端返回推荐结果后前端用卡片流展示。真实场景里用户体验很依赖图片加载速度所以我在卡片图片上做了懒加载el-image :srcitem.cover_url fitcover lazy classattraction-cover /图片懒加载看似小细节但在列表页二三十张图片一次性加载的场景下首屏速度至少提升一半。4. 联调、部署与问题排查4.1 前后端联调与代理配置前后端分离开发时联调阶段最常见的问题就是跨域。Django后端默认不开启CORSVue前端开发服务器默认跑在5173端口两者端口不同必出跨域错。开发环境最简单的方案是用Vite的代理把前端请求代理到后端地址这样浏览器侧看到的请求是同源的可以完美避开跨域。配置在vite.config.js中export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { /api: { target: http://127.0.0.1:8000, changeOrigin: true } } } })我把Axios的baseURL设置成了/api配合Vite代理转发开发阶段完全不用管CORS。但如果前端是独立部署的那就必须在Django里配置跨域了# settings.py 中安装 django-cors-headers 后的配置 INSTALLED_APPS [ # ... corsheaders, ] MIDDLEWARE [ # ... corsheaders.middleware.CorsMiddleware, django.middleware.common.CommonMiddleware, ] CORS_ALLOW_ALL_ORIGINS False # 生产环境建议关闭 CORS_ALLOWED_ORIGINS [ http://localhost:5173, https://travel.example.com, ]区分一下开发阶段用开发代理生产阶段用CORS白名单两件事不冲突都配置好就行。4.2 PyCharm中的配置与调试PyCharm是这套开发流程的主力IDE。建议从官网下载社区版如果你所在的公司或学校有正版授权可以考虑Professional版。社区版配合插件功能已经能覆盖Vue和Django的主要开发场景。关于版本的坑有两点我要专门提醒一是Python解释器版本建议直接用Python 3.9或3.10不要用太高或太低的版本。Django 4.x要求Python 3.8以上3.11、3.12也能跑但个别第三方库的兼容性跟不上。稳妥起见用3.10。二是PyCharm里跑Django的时候项目根路径和manage.py路径的配置。很多人启动项目报ModuleNotFoundError就是因为运行配置里的“Working directory”没有正确指向manage.py所在目录。配置调试的方法点击右上角运行配置下拉框选择“Edit Configurations”新增一个“Django Server”配置Name随意在“Working directory”里选择后端工程根目录在“Parameters”里填入runserver 0.0.0.0:8000这样点Debug按钮可以直接在PyCharm里断点调试Python代码前端Vue的调试也需要在PyCharm中配置。PyCharm Professional自带Node.js插件社区版可以通过安装“Vue.js”插件获得Vue文件语法高亮。运行时直接在Terminal里敲npm run dev就够了开发服务器热更新很好用。4.3 项目部署要点Django和Flask的部署我分别说一下。Django部署最规范的方式是Nginx Gunicorn Django。Gunicorn是Python的WSGI服务器比Django自带的runserver稳定得多适合生产环境。pip install gunicorn # 启动命令3个worker进程 gunicorn config.wsgi:application -w 3 -b 127.0.0.1:8000Flask推荐服务部署方式差不多也可以直接用Gunicornpip install gunicorn gunicorn app:app -w 2 -b 127.0.0.1:5001Flask程序内部app:app意思是导入app.py文件中的app对象。如果Flask的入口文件名不是app.py对应调整模块名即可。数据库方面开发阶段用SQLite足够部署上线换成MySQL。Django切数据库很方便只要改settings.py里的DATABASES配置然后执行python manage.py makemigrations python manage.py migrate但要注意SQLite和MySQL的字段类型有些差异比如JSONField在MySQL需要5.7以上DecimalField精度在不同数据库下行为可能不同。所以建议项目早期就建好生产数据库的迁移不要拖到上线前再切不然数据迁移会非常痛苦。4.4 常见问题速查表开发这套系统过程中我把踩过的坑整理成了一张速查表分享出来供大家参考。问题现象解决方案中文乱码Django返回JSON含中文显示\uXXXX配置JSON_AS_ASCII False或使用JsonResponse的json_dumps_params跨域请求失败Vue请求后端报CORS错误开发环境配置Vite代理生产配置CORS白名单数据库迁移冲突makemigrations提示已有同ID迁移文件删除migrations目录下冲突文件保留000_init.py重新生成端口被占用启动runserver提示Address already in useLinux执行lsof -i:8000查占用进程并killORM误删数据删除景点后收藏全部丢失定义外键时慎重设置on_delete生产数据先备份Vue打包后路径错误静态资源404vite.config.js中设置base: ./PyCharm终端报ModuleNotFoundError没有激活虚拟环境终端里先执行source venv/bin/activateElement Plus按需引入不生效样式缺失检查是否安装了unplugin-vue-components及相关插件axios请求返回字符串而非对象响应拦截器处理不当检查后端Content-Type是否为application/jsonFlask接口数据乱码返回中文变???Flask设置app.config[JSON_AS_ASCII] False或指定UTF-8针对第一个问题再补充一下Django 3.x之后JsonResponse默认会把中文转成ASCII码返回浏览器能正常显示但调试时很难看。建议在settings.py里这样配置import json from django.core.serializers.json import DjangoJSONEncoder # 在响应时手动指定 response JsonResponse(data, json_dumps_params{ensure_ascii: False})关于PyCharm安装Python库包有人在热词里问“pycharm怎么安装pandas包”一类的问题。其实不需要在PyCharm里手动安装直接在Terminal里执行pip install pandas即可安装完成之后PyCharm会自动识别虚拟环境中的包。如果安装慢可以使用国内镜像源pip install pandas -i https://pypi.tuna.tsinghua.edu.cn/simple5. 项目演进与更多可能性这个系统的基础版本做完之后还有几个方向可以继续演进我这里按照成本从低到高排个序。先是最容易做的在现有Flask推荐服务中加入Redis缓存后再接入定时更新逻辑。比如每天凌晨根据前一天的浏览和收藏记录用离线任务重新计算热度榜并把结果预热到Redis这样推荐接口的响应时间能稳定控制在100ms以内。然后是推荐算法升级。现有ItemCF没有考虑时间衰减一个用户三个月前看的景点的权重和三天前看到的完全相同。可以改造评分函数def time_decay(days): return 1 / (1 days * 0.1)将交互时间做指数衰减近期的行为权重更高推荐结果会更符合用户当前口味。这是经典推荐系统从“能用”到“好用”的关键一步。架构层面如果系统数据量继续增长Django侧可以做读写分离、接口缓存Flask推荐服务可以改用FastAPI做异步前端可以做服务端渲染提升SEO。但这些都是后话项目初版能把全流程跑通、数据流清晰、代码结构规范已经胜过很多“技术上堆得高但逻辑一团糟”的系统。根据我自己的实操经验做这类全栈项目的核心思路是先把数据模型和接口约定定清楚再分配前后端开发任务联调效率会高很多。我就是一开始急着写前端页面结果后端接口返参结构反复调整改了好几次才稳定下来。如果重新做我会先和后端定一个OpenAPI文档用Apifox或Swagger把每个接口的入参出参固定住开发体验会舒服很多。再分享一个关于推荐效果调优的体会不要迷信算法先把数据进行可视化。把用户的浏览、收藏、城市分布做几张图表出来你会发现推荐不准的原因往往是数据稀疏或者行为采集不全而不是算法不够高级。把埋点做好、把行为数据记录完整比把协同过滤做得复杂得多管用。这也是为什么我在上文反复强调UserAction的重要性。最后建议各位项目源码一定用Git管理每个功能模块做完就提交一次。这套系统虽然不大但Django和Flask之间、后端和前端之间、业务逻辑和推荐逻辑之间一旦串起来改动难免牵一发动全身。有版本控制兜底改坏了随时可以回退这一步省不了。
返回列表