基于SpringBoot+Vue的大学城就餐推荐系统:偏好建模与实时推荐
简介:本资源是一套面向松江大学城大学生的智能餐饮推荐系统完整工程代码,基于SpringBoot+Vue技术栈实现,聚焦高校场景下的个性化餐厅推荐痛点——解决学生因信息过载、位置分散、口味偏好多样导致的就餐决策低效问题。压缩包共1053个文件(15.01MB),涵盖112个Java后端业务与接口逻辑文件、132个Vue组件及页面(如IndexMain.vue、update-password.vue等)、133个JS交互脚本、244个PNG/SVG图标资源,以及JSON配置、XML配置、YML启动参数等关键支撑文件,结构清晰,模块划分明确,便于理解前后端协同机制与推荐逻辑落地。已有106人学习下载,开发者可直接运行调试,完整掌握用户偏好建模、多维度评分融合、LBS地理位置服务集成、实时数据采集更新等核心功能实现细节,并复用其SpringBoot RESTful接口设计、Vue组件化开发范式与数据库表结构设计思路。
1. 从大学城痛点说起:这个推荐系统到底在解决什么问题
做这个项目之前,我其实先花了很长时间想一个问题:松江大学城的学生吃饭,真的需要一个推荐系统吗?
打开美团、大众点评,不是照样能看评分、看距离、看人均消费?答案在逻辑上成立,但实际用下来完全不是那么回事。我自己在松江大学城待了四年,太清楚这个场景的特殊性了——大学城不是一个普通商圈,它的用户群体极度集中,但需求分化得非常厉害。爱吃辣的、忌口的、赶时间的、想吃清淡的、月底吃土的、周末聚餐改善伙食的,全挤在几个食堂和周边几条商业街里。通用外卖平台的推荐逻辑是"根据你历史订单推荐相似的店",但大学生的就餐决策往往受课程表(下课时间)、预算(月底余额)、同行人(室友、对象、社团)这些动态因素影响,根本不是"相似口味"能覆盖的。
更关键的一点是:大学城周边的餐厅更新速度极快。今天还在营业的店,下个月可能就换了老板;这学期突然火起来的网红店,可能两周后就因为排队长被学生拉黑。通用平台的数据更新机制做不到这种颗粒度的实时反馈——它们更关心商家的广告投放和平台交易抽成,而不是一个学生社团群里疯传的"某家店今天出餐巨慢"的真实评价。
所以我做这个系统的核心定位很明确:不做一个"大众点评的大学城分站",而是做一个真正服务于大学生就餐决策的轻量级智能推荐平台。它的核心价值在于三点:
第一,偏好建模更细。不只是"你爱吃辣"这种粗粒度标签,而是结合价格敏感度、口味倾向、用餐时段、用餐人数等多维特征做用户画像。
第二,实时性更强。把学生群体的实时反馈引入推荐权重,比如某家店今天排队特别长、某个窗口今天有特价菜、某家店最近口碑下滑,这些信息能快速影响推荐排序。
第三,地理位置更聪明。大学城的特点是"宿舍-教学楼-食堂-商业街"几个点之间的移动,不是"我在哪、附近有什么"这么简单。系统需要理解用户的当前位置和当前场景(比如刚下课、在宿舍、在图书馆),才能给出真正有意义的推荐。
技术选型上,我采用了SpringBoot + Vue的前后端分离架构。这个组合在Java技术栈里是相当成熟且适合课程设计级别的项目,SpringBoot负责提供RESTful API和推荐算法服务,Vue负责前端交互和可视化呈现。项目虽然叫"松江大学城就餐推荐系统",但整体设计思路和核心实现完全可以迁移到任何大学城或者园区场景。
2. 技术架构选型:SpringBoot+Vue前后端分离背后的取舍逻辑
2.1 后端为什么选SpringBoot而不是其他框架
推荐系统本身对后端的核心要求是"能快速开发、易维护、生态成熟"。在Java阵营里,SpringBoot几乎是唯一不需要纠结的选择。Spring全家桶提供了从Web开发、数据持久化(MyBatis/JPA)、缓存(Redis)到安全认证(Spring Security)的一整套方案,省去了大量繁琐的XML配置。
有个细节值得说:SpringBoot的自动配置机制对推荐系统这种业务逻辑复杂但基础设施需求标准的项目特别友好。比如我在项目中集成了Redis缓存热门推荐结果、集成WebSocket做实时数据推送,SpringBoot的Starter机制让我只需要在pom.xml加几个依赖,再写少量配置类就能跑起来,不用像传统SSM框架那样手写一堆XML。
版本选择上,我用了SpringBoot 2.7.x而不是最新的3.x。原因很实际:3.x要求JDK17起步,而很多学校机房和实验室的环境还停留在JDK8;另外一些老牌的推荐算法依赖库(比如Mahout的某些模块)对JDK17的兼容性并不好。如果你也是做课程设计或者毕业设计,建议优先选择2.7.x版本,避免在环境兼容性上浪费大量时间。
2.2 前端为什么选Vue,以及组件化拆分的思路
前端选择Vue的原因更偏向实际体验。Vue对新手非常友好,模板语法直观,双向绑定机制让表单交互和用户偏好设置的开发效率很高。而且Vue生态里的Element UI组件库,帮我解决了80%的后台管理页面样式问题。
在项目里,我把前端拆成了几个核心模块:
- 推荐首页:展示个性化推荐的餐厅卡片流,支持下拉刷新和筛选
- 偏好设置页:用户的标签偏好、价格区间、忌口设置
- 餐厅详情页:评分详情、位置地图、用户评论
- 实时反馈组件:排队情况、今日特价、出餐速度的实时状态展示
组件化拆分的原则很简单——每个页面尽量做成"逻辑独立、数据驱动"的组件。比如推荐卡片流是个通用组件,接收一个餐厅对象数组,渲染成卡片列表;地图定位是一个独立组件,封装了高德地图的JS API调用。后期维护时改推荐算法逻辑或者换地图服务商,前端都不需要大动。
2.3 数据库选型与核心数据表设计
数据库我用的是MySQL 8.0 + Redis 5.0的组合。MySQL负责核心业务数据的持久化存储,Redis负责缓存热门推荐结果、用户会话和实时反馈数据。
核心数据表的设计是系统的地基,我花了不少心思。一共设计了12张表,其中最核心的5张是:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 用户基本信息 | id, username, password, avatar, major(专业) |
| user_preference | 用户偏好画像 | user_id, taste_tags, price_range, spice_level, dietary_restrictions |
| restaurant | 餐厅基础信息 | id, name, category, address, longitude, latitude, avg_price |
| rating | 多维评分记录 | id, restaurant_id, user_id, taste_score, env_score, service_score, price_score |
| realtime_status | 实时状态数据 | id, restaurant_id, queue_length, special_offer, serving_speed, update_time |
以user_preference表为例,它是推荐算法的数据基础。taste_tags字段用逗号分隔存储多个口味标签,比如"川菜,烧烤,奶茶";spice_level用1-5的整数表示辣度接受度;price_range是价格敏感度级别。这样设计的好处是查询方便,MyBatis的<foreach>标签可以直接处理逗号分隔的字符串,不需要额外的关联表。
rating表的设计决定了多维评分功能的可实现性。我设置了四个评分维度:口味、环境、服务、性价比,每个都做成独立字段而不是一个总分字段。这样设计不是为了多几个字段好看,而是让推荐算法可以分别加权处理——比如赶时间的学生可能更看重出餐速度,月底吃土的学生可能更看重性价比,不同场景下四个维度的权重完全不同。
2.4 整体架构与数据流转过程
整个系统的架构可以用一张简化的数据流来描述:
用户操作(浏览、评分、反馈)→ Vue前端 → SpringBoot接口层 → 业务逻辑层(偏好分析、推荐算法)→ 数据层(MySQL + Redis)→ 推荐结果返回前端渲染
一个完整的推荐请求处理流程是这样跑的:
- 用户打开推荐首页,前端带
userId和当前经纬度调用/api/recommend/list - 后端先去Redis查有没有该用户的缓存推荐结果,如果有直接返回(缓存策略是5分钟有效)
- 如果没有缓存,推荐服务加载用户偏好画像(从MySQL的
user_preference表读取) - 从
restaurant表查询当前范围内的全部餐厅,逐一计算推荐得分 - 得分Top20的餐厅组装成推荐列表,同时在前端触发地图标注
- 推荐结果写入Redis缓存,同时异步记录用户本次浏览行为
这个流程看起来简单,真正调试的时候每一环都有坑。我在后面专门写一节"踩坑实录"详细说。
3. 推荐引擎设计:用户偏好分析从0到1的实现
推荐引擎是整个系统的灵魂。说实话,这个项目里最花时间的就是这部分,因为推荐系统的难点不在于算法本身,而在于怎么把用户行为数据"翻译"成推荐信号。
3.1 偏好数据的采集:显式与隐式反馈
用户偏好数据的采集分为两类:显式反馈和隐式反馈。
显式反馈就是用户主动告诉系统的信息,包括注册时选择的兴趣标签、手动设置的偏好、提交的评分和评论。这套机制我在user_preference表里已经设计好了,实现难度不大。
隐式反馈才是重点。用户的真实偏好往往不会明说,而是藏在行为里:点击了哪些餐厅卡片、在哪个餐厅详情页停留了很久、频繁搜索某种菜系、某家店的推荐曾被划走但后来又点了进去。这些行为数据我单独建了一张user_behavior_log表,通过前端埋点上报到后端。
埋点的实现并不复杂,就是在Vue的路由守卫和关键组件里加一个异步上报方法:
// 在Vue组件中记录用户行为 export function logBehavior(behaviorType, targetId, duration) { navigator.sendBeacon('/api/behavior/log', JSON.stringify({ userId: store.state.userId, behaviorType, // 'CLICK' / 'VIEW' / 'SEARCH' / 'SKIP' targetId, duration })); }用navigator.sendBeacon而不是普通的fetch/AJAX,是因为这个API在页面关闭时也能可靠地发送数据,不会丢失行为日志。
行为数据不是直接进推荐算法的,而是先做一层"行为权重化"处理:
| 行为类型 | 权重值 | 说明 |
|---|---|---|
| 提交评分 | 5.0 | 最有价值的显式反馈 |
| 浏览详情超过30秒 | 2.0 | 深度兴趣信号 |
| 点击卡片 | 0.8 | 轻度兴趣信号 |
| 搜索某菜系 | 1.5 | 明确意图信号 |
| 推荐结果被跳过 | -1.0 | 负反馈信号 |
这套权重体系不是拍脑袋定的,我参考了协同过滤领域常用的行为-兴趣映射表,然后根据大学城场景做了调整——比如"搜索菜系"的权重我调高了,因为大学生搜"火锅"基本就是打算去吃火锅,意图比随便逛逛要强得多。
3.2 基于物品的协同过滤与基于标签的过滤怎么融合
推荐算法的核心我用了融合推荐策略:基于物品的协同过滤(ItemCF)+ 基于标签的过滤(Tag-based Filtering) + 地理位置加权。
先做协同过滤。ItemCF的思路是"喜欢这个餐厅的人也喜欢那个餐厅",核心计算是餐厅之间的相似度矩阵。我用的是余弦相似度(Cosine Similarity),评分为向量:
public double cosineSimilarity(Map<Long, Double> ratingVecA, Map<Long, Double> ratingVecB) { double dotProduct = 0.0; double normA = 0.0; double normB = 0.0; // 只计算两个用户共同评分过的餐厅 for (Long itemId : ratingVecA.keySet()) { if (ratingVecB.containsKey(itemId)) { dotProduct += ratingVecA.get(itemId) * ratingVecB.get(itemId); } normA += Math.pow(ratingVecA.get(itemId), 2); } for (Double rating : ratingVecB.values()) { normB += Math.pow(rating, 2); } if (normA == 0.0 || normB == 0.0) return 0.0; return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)); }这里有个实际工程问题:大学城能覆盖的餐厅数量大概在200-500家之间,用户数可能上万,如果实时计算全量相似度矩阵,性能撑不住。我的解决方案是离线预计算+在线查询:每天晚上用定时任务计算一次餐厅相似度矩阵,存入Redis的Hash结构,在线推荐时直接查Redis取值。
协同过滤有一个天然缺陷:新餐厅或者评分数据少的餐厅(冷门餐厅)几乎不会被推荐。大学城的餐饮生态里,新店是常态,所以必须在协同过滤之外叠加基于标签的过滤。
基于标签的过滤实现起来更直接:把每个餐厅打上标签("川菜"、"烧烤"、"奶茶"、"轻食"、"东北菜"、"人均20以下"等),然后计算用户偏好标签和餐厅标签的匹配度。用户的偏好标签从user_preference表读取,同时用行为日志中高频出现的搜索词和点击类别做动态补充。
最终的推荐得分我用一个加权公式融合:
finalScore = 0.4 * itemCFScore + 0.4 * tagMatchScore + 0.2 * locationScore权重比例不是固定的,可以根据使用效果调整。我刚上线时用的是0.4:0.4:0.2,后来通过A/B测试发现大学城场景下地理位置的影响被低估了,改成了0.35:0.35:0.3,推荐结果的点击率有可见提升。
3.3 冷启动问题怎么处理
新用户没有历史行为数据,协同过滤直接失效;新餐厅没有评分数据,协同过滤也失效。这就是推荐系统里经典的冷启动问题。
新用户的处理方案是"注册即画像":在用户注册引导页强制采集基础偏好信息——口味标签(多选)、价格区间(单选)、是否忌口(多选)。用这些采集到的标签直接跑标签匹配过滤,虽然精度不高,但至少能给出不算离谱的初始推荐。等用户产生一定的浏览和评分行为后,再切换为融合推荐。
新餐厅的处理方案是"带量扶持"策略:给新餐厅一个基础评分加成。比如新餐厅的基础评分设为该品类平均分的1.1倍,持续7天,让它们有机会进入推荐候选列表。这个加成是临时的,7天结束后回到正常评分逻辑。这个思路借鉴了电商平台的"新品流量扶持",实际效果还不错——新入驻的店铺在一周内获得的曝光量明显高于没有加成的时候。
用户的实时行为数据也会参与权重调整。比如某个用户连续两次点了"川菜"标签的搜索结果,即使他注册时没选辣度偏好,系统也会自动把"川菜"加入他的动态偏好标签集合。
3.4 推荐结果的排序策略
推荐列表不能只看得分,还要考虑多样性。如果一个用户特别喜欢吃烤串,系统按得分排序把Top20全是烤串店推给他,这个推荐体验其实很糟糕——用户不会觉得系统懂他,反而会觉得"周围只有烤串吗?"
我引入了一个简单的多样性策略:品类轮换。按品种类别(火锅、烧烤、快餐、奶茶、日料、中式正餐等)分组,先从每个组里取得分最高的2家店进入推荐池,再用总分排序。这样既保证用户偏好的食物类型有更多选择,又不至于让推荐列表变成单一品类。
排序的最终参数还可以叠加实时状态。比如某家店realtime_status表里记录当前排队人数为30人,推荐级别要降权;如果special_offer字段有特价活动,加权提升。这种动态调整是通用外卖平台做不到的,也是这个系统"实时数据更新"特色的具体落地。
4. 多维度评分与实时数据更新机制
评分系统是推荐系统的"燃料",但很多设计都把评分做成了伪多维度——表面上分了几个分,实际上用户只会打个总分,四个维度的评分严重关联。我的设计思路是正面解决这个问题。
4.1 评分维度怎么设计才不算"伪多维度"
我设置的四个维度是:口味、环境、服务、性价比。每个维度单独打分,范围1-5分,页面用星级组件展示。
关键设计是评分页的交互顺序。传统做法是用户进详情页就给一个"评分"按钮,点开一次性打完四个分提交。我改成了分步引导——用户必须先后评价口味和性价比,环境和服务的评分权重相对较低,但也要各给一个。之所以这样设计,是因为前两个维度是用户决策的核心依据,后两个是辅助依据。我在实际操作中发现,一次性弹四个纬度会让用户觉得麻烦,随便点几个分就提交了;分步引导虽然多一步操作,但每个维度的评分更有区分度,推荐算法的输入质量高了很多。
这些评分数据的预处理还包括异常值检测。比如某个用户给所有餐厅都打5分,或者某个餐厅的评分突然从4.8暴跌到2.0,都要触发异常检测逻辑。我的方案很简单:对用户的评分做标准差分析,低于阈值的视为"老好人评分",在计算用户偏好时做降权处理;对餐厅评分则用Z-score检测,超过3个标准差的评分视为异常,需要人工审核。
4.2 评分的归一化与加权计算
多维度评分进入推荐算法前,必须做归一化处理。不同用户的评分习惯完全不同,有人觉得4分就是不错,有人觉得4分是踩雷。直接把原始分相加会有严重的偏差。
我采用的做法是用户中心化偏移量归一化。具体来说,对每个用户的评分做处理:
normalizedScore = userScore - userAvgScore每个用户对餐厅的评分先减去该用户的历史平均评分,得到一个相对偏移量。这个偏移量消除了"手松"和"手紧"的用户偏差,让协同过滤的相似度计算更准确。
餐厅的最终综合评分overallScore则用各维度加权平均计算,权重不是固定的,而是根据用户偏好动态调整:
public double calcOverallScore(Restaurant restaurant, UserPreference preference) { double tasteWeight = 0.35; double priceWeight = 0.25; double envWeight = 0.2; double serviceWeight = 0.2; // 根据用户偏好动态调整权重 if (preference.getPriceSensitivity() >= 4) { priceWeight = 0.45; tasteWeight = 0.25; } if (preference.getSceneType() == SceneType.COURSE_GAP) { // 课间用餐,服务权重提升(对应出餐速度) serviceWeight = 0.4; tasteWeight = 0.25; envWeight = 0.05; } return restaurant.getTasteScore() * tasteWeight + restaurant.getPriceScore() * priceWeight + restaurant.getEnvScore() * envWeight + restaurant.getServiceScore() * serviceWeight; }这个动态加权设计是整个项目里我最满意的一部分。它让"多维度评分"真正活了起来,而不是一个展示性的功能。赶时间的学生和追求环境的学生看到的是完全不同的"综合评分"。
4.3 实时更新的实现方案:定时任务还是消息推送
"实时数据更新"是这个系统的核心卖点之一,但实现方案需要仔细权衡。我评估了几种方案:
- 定时轮询方案:前端每隔一段时间主动拉取状态数据。实现简单,但存在延迟和流量浪费。
- WebSocket推送方案:服务端主动推送状态变更到前端。实时性好,但需要维护长连接,实现复杂度更高。
- SSE(Server-Sent Events)单工推送方案:服务端向客户端单向推送,实现比WebSocket简单,适合"服务端状态变更→客户端展示"的场景。
最终我选择了SSE + 定时任务兜底的组合方案。SSE处理实时状态变化,比如用户提交新的评分后,系统立即更新餐厅评分并向关注该餐厅的用户推送更新;定时任务兜底处理异常情况,比如某个餐厅的数据如果超过15分钟没有更新,强制重新采集一次。
SSE在SpringBoot里的实现很简洁:
@RestController public class RealtimeStatusController { private final SseEmitter realtimeEmitter = new SseEmitter(0L); @GetMapping("/api/realtime/subscribe") public SseEmitter subscribe() { return realtimeEmitter; } // 模拟状态变化推送 @PostMapping("/api/realtime/status/update") public void updateStatus(@RequestBody RealtimeStatus status) { // 更新数据库和缓存 realtimeStatusService.update(status); // 推送给前端 realtimeEmitter.send(SseEmitter.event() .name("statusUpdate") .data(status)); } }前端用EventSource接收推送:
const eventSource = new EventSource('/api/realtime/subscribe'); eventSource.addEventListener('statusUpdate', (event) => { const data = JSON.parse(event.data); updateRestaurantCard(data.restaurantId, data.status); });4.4 缓存与数据一致性的权衡
引入Redis之后,最常见的坑就是缓存和数据库的数据不一致。推荐结果缓存了5分钟倒还好,但实时状态数据如果缓存时间过长,用户看到的就是"假实时"。
我的策略是分级缓存时效:
| 数据类型 | 缓存时间 | 理由 |
|---|---|---|
| 用户偏好画像 | 30分钟 | 偏好变化频率低 |
| 推荐结果列表 | 5分钟 | 在准确性和新鲜度间平衡 |
| 餐厅基础信息 | 1小时 | 变化很少 |
| 实时状态(排队等) | 不缓存 | 直接查数据库,保证真实 |
实时状态不缓存这个决策带来了一定的数据库压力,但买单的是正确性。大学城场景下餐厅数量也就几百家,每个餐厅的状态更新频率不高,直接查库完全扛得住,没必要为了省这点查询量牺牲实时性。
5. 地理位置服务整合:距离计算与范围筛选的实战
地理位置服务是这个项目区别于普通推荐系统的差异化功能,也是我做得比较细的一部分。
5.1 地图选型与API接入
地图服务商我选的是高德地图。原因很直接:高德的JavaScript API和Web服务API都对个人开发者免费,而且在国内校园场景的定位准确度明显优于其他家——大学城这种建筑密集、道路复杂的区域,对定位精度的要求比普通城市街区更高。
后端通过高德的Web服务API做逆地理编码和路径规划。前端通过JS API加载地图、标注餐厅位置、展示当前位置到餐厅的路线。
注册高德地图开发者账号后,需要配置两个Key:一个是Web端(JS API)的Key,配置域名白名单;一个是服务端(Web服务)的Key,配置IP白名单。这里有个容易踩的坑:两个Key不能混用,JS API的Key如果用来调服务端接口,会直接返回INVALID_USER_KEY错误。
MyBatis里的地理位置查询是我自己写的SQL,没有用第三方的地理工具库。核心是一个按经纬度范围筛选的查询。高德返回的经纬度是GCJ-02坐标系,存库之前不用转换,因为前端地图也是GCJ-02,保持同坐标系反而省事。
5.2 距离计算方式的选择:球面距离还是平面距离
计算两个坐标点的距离,最精确的方法是用Haversine公式(球面距离),它考虑地球曲率。但在实际项目中,我一开始用Haversine,后来改成了简化的平面距离计算。
为什么简化?因为松江大学城的覆盖范围大约在5公里半径内,在这么小的范围内,球面曲率带来的误差不到米的级别,完全可忽略。而Haversine公式里涉及大量的三角函数运算,在数据库查询时无法使用索引优化,每个候选餐厅都要计算一次,性能和复杂度都更高。
简化后的距离用等距圆柱投影近似即可,在Java中实现非常高效:
public double calcDistance(double lat1, double lon1, double lat2, double lon2) { double avgLat = (lat1 + lat2) / 2; double x = (lon2 - lon1) * 111320 * Math.cos(Math.toRadians(avgLat)); double y = (lat2 - lat1) * 110540; return Math.sqrt(x * x + y * y); }这里111320和110540分别是经度和纬度方向上每度对应的米数近似值,cos(avgLat)是对经度在不同纬度上距离衰减的修正。在小范围内这个公式的精度完全够用,而且比Haversine快一个量级。
5.3 结合地理位置的推荐加权逻辑
地理位置的加分逻辑需要区分场景。我在系统里定义了三种典型场景:
- 课间用餐(时间紧):距离权重极高,1公里以内的餐厅优先,超过2公里的直接过滤掉
- 宿舍宅(在宿舍点外卖或下楼吃):距离权重中等,辐射范围3公里
- 周末聚餐(有社交需求):距离权重降低,更看重评分和口碑
场景的识别主要依靠用户当前的位置和时段。位置信息前端通过浏览器API获取经纬度,后端再和用户常用位置做对比——比如用户如果在教学楼区域,且当前时间是11:30-13:00或17:00-18:30,就判定为"课间用餐"场景。
位置加权用分段函数而不是线性函数实现:
locationScore = { 距离 < 500米: 1.0 距离 500-1000米: 0.8 距离 1000-2000米: 0.5 距离 > 2000米: 0.2(聚餐场景下为0.4) }分段的好处是权重不会因为距离的微小变化产生剧烈波动,推荐结果更稳定。
5.4 附近餐厅搜索的性能优化
数据库里存了每家餐厅的经纬度,最直观的查询方式是"遍历所有餐厅,计算距离,筛掉超范围的"。这个方案在餐厅数量少时没问题,但如果餐厅数量增长到几千家,每次请求都全表扫描绝对会变成性能瓶颈。
我采用了经纬度范围预筛选的方式。思路是先把大范围筛掉,再在应用层做精确计算:
SELECT * FROM restaurant WHERE latitude BETWEEN #{minLat} AND #{maxLat} AND longitude BETWEEN #{minLon} AND #{maxLon}minLat、maxLat、minLon、maxLon由中心点经纬度和半径计算得出。比如搜索半径是2公里,经纬度各加减约0.018度即可覆盖。这能把扫描范围缩小几个数量级,然后在应用层对这些候选餐厅做精确距离计算和排序。在latitude和longitude字段上建立联合索引后,查询性能非常理想。
6. 关键功能的前后端实现细节
6.1 用户登录与偏好画像维护
登录模块我用的是Spring Security + JWT的方案。JWT(JSON Web Token)适合前后端分离下的无状态认证,服务端不需要存储会话信息,前端拿Token放请求头里即可。
登录流程是:
- 前端提交用户名密码
- 后端校验通过后生成JWT,Token里包含
userId、username、过期时间 - 前端把Token存入localStorage
- 后续请求在axios拦截器里统一添加
Authorization: Bearer <token>请求头
偏好画像的维护在登录后的向导页完成。注册时只收集用户名密码,登录后弹出一个偏好设置向导——选择口味标签、价格区间、忌口、用餐时段偏好。这个向导的数据直接写入user_preference表,后续可以在个人中心随时修改。
一个细节:偏好画像的修改会立即清空该用户在Redis里的推荐缓存,保证下次请求推荐列表时用最新的偏好重新计算。如果不清缓存,用户改了偏好后看到的还是老推荐,体验会很差。
6.2 推荐列表接口的设计与联调
推荐列表接口/api/recommend/list是前端最核心的接口,它的请求参数和响应结构我做了精心的设计:
// 请求参数 { "userId": 10001, "latitude": 31.0592, "longitude": 121.2237, "sceneType": "COURSE_GAP", // COURSE_GAP / DORM / WEEKEND "priceFilter": null, // 可选的价格过滤 "tagFilter": null // 可选的标签过滤 }// 响应结果 { "code": 0, "message": "success", "data": { "recommendList": [ { "restaurantId": 201, "name": "老四川冒菜", "category": "川菜", "tags": ["麻辣", "冒菜", "人均25"], "overallScore": 4.7, "distance": 380, "locationScore": 0.8, "recommendReason": "偏好匹配度高,你常点川菜;距离教学楼仅380米;今日有特价套餐", "realtime": { "queueLength": 5, "specialOffer": "下午2点前下单送饮料", "servingSpeed": "快" } } ], "totalCount": 20 } }recommendReason字段是给用户看的推荐解释。这一点很重要——推荐系统不能是个黑盒,用户需要知道"为什么推荐这家"。大学城学生的信任度是运营出来的,有推荐的解释文案,用户更愿意尝试系统推荐的新餐厅。
前端是Vue + Element UI,推荐首页的组件结构大致是:
recommend-view.vue:页面主组件,负责获取推荐数据和分发事件restaurant-card.vue:单张餐厅卡片,展示评分、距离、实时状态、推荐理由restaurant-map.vue:地图组件,接收餐厅列表数据,在地图上打点filter-bar.vue:筛选栏,支持按价格、标签、距离排序
卡片流我用的是无限滚动加载,底部没有"加载更多"按钮。UI层用el-card组件加自定义样式,整体风格清爽,符合大学城餐饮的年轻化调性。
6.3 餐厅详情与评分展示
餐厅详情页是用户决策的关键一环。页面包含五块内容:基本信息(图片、名称、品类、地址)、综合评分(雷达图展示四个维度)、地理位置(地图和导航)、用户评价(按时间倒序)、实时状态(排队、特价、出餐速度)。
雷达图我用了ECharts的雷达图组件。ECharts对Vue的兼容性很好,通过vue-echarts包装器就能直接当Vue组件用。评分数据从/api/restaurant/{id}/ratings接口获取,后端聚合四个维度的平均值返回。
评分雷达图的数据展示效果很重要,它让"多维度评分"变得直观可见。一个餐厅如果在"性价比"维度得了4.9分,在"环境"维度只有2.8分,用户一眼就能看出"这家店味道好但环境一般",决策效率极高。
用户评价功能的实现比较标准:用户提交评分和文字评论,后端写入rating表和comment表。评论区支持点赞,点赞数会影响评论的排序权重。考虑到大学城场景,我做了个简单的"同专业优先展示"——如果当前用户和评论者专业相同,评论排在更前面,这样更容易看到"学长学姐的真实意见"。
7. 踩坑实录与调优经验
这部分是我最想写的。这个项目从0到1的过程中,踩过的坑比预想中多得多,挑几个印象最深的分享一下。
7.1 协同过滤算法在数据稀疏时的表现问题
系统刚上线测试时,用户量只有几十个,每人的评分数据也很少,推荐结果非常糟糕——很多用户收到的推荐列表几乎一样,根本体现不出"个性化"。
我排查了好几天,最终定位到原因是ItemCF在数据稀疏时退化成热门推荐。用户评分矩阵太稀疏,相似度计算找不到足够的有意义交集,导致所有用户的最相似餐厅都是那几个被几个人评过分的小众选择。
解决办法是引入了两个兜底机制:
第一,数据不足时使用基于标签的过滤作为主推荐。当用户评分条数少于10条时,跳过协同过滤,直接用标签匹配和地理位置加权推荐。等评分数据积累超过10条,才切换为融合推荐。
第二,相似度计算前先补全评分向量。对用户没有评分过的餐厅,用该用户偏好标签匹配度填充一个"估计评分",这样相似度计算不再受稀疏矩阵的严重影响。
这个问题的本质是"数据量不够时不要盲目上复杂算法",先用简单的规则引擎保证基础体验,再逐步引入算法优化。
7.2 地图API在校园环境的定位偏差
大学城环境里,定位偏差是个很让人头疼的问题。宿舍楼和食堂之间距离可能只有几百米,但如果定位偏差200米,推荐结果就可能把该推的店排到了后面。
我一开始直接用浏览器navigator.geolocation获得经纬度传给后端,结果发现两个问题:一是定位精度受设备影响大,手机和电脑差别明显;二是教学楼、食堂这些建筑会对GPS信号造成干扰,偏差能到300米以上。
优化方案是"多源定位融合":
- 优先使用高德JS API的
AMap.Geolocation插件,它融合了GPS、Wi-Fi和基站信号,在室内的定位效果比浏览器原生API好很多 - 如果高德定位状态不是
complete,前端提示用户手动选择当前位置(给出常用位置列表:宿舍区、教学楼区、图书馆、食堂区、商业街) - 后端根据用户的历史常驻位置,对定位结果做合理性校验——如果用户突然出现在一个从未去过的地方,且距离上次位置超过5公里,触发异常提示
手动选择位置这个方案虽然"土",但在校园场景下极其有效。学生主要活动范围就那么几个区域,点选比等GPS精准定位还快。
7.3 数据库索引优化:为推荐查询建对了索引
推荐列表查询涉及restaurant表的经纬度筛选、rating表的聚合计算、realtime_status表的最新状态查询。最初所有表都没建索引,接口平均响应时间800多毫秒,用户体验明显卡顿。
用EXPLAIN分析后发现,主要瓶颈在restaurant表的全表扫描和rating表的聚合查询。优化措施:
-- 经纬度联合索引,加速范围查询 ALTER TABLE restaurant ADD INDEX idx_lat_lon (latitude, longitude); -- 联合索引,加速评分的聚合查询 ALTER TABLE rating ADD INDEX idx_restaurant_user (restaurant_id, user_id); -- 实时状态表的餐厅ID+时间索引 ALTER TABLE realtime_status ADD INDEX idx_restaurant_time (restaurant_id, update_time);加了索引之后,同样的接口响应时间从800ms降到80ms左右。索引设计一定要基于实际查询模式,不要所有字段都加索引,多余的索引会拖慢写入性能。
7.4 前端路由懒加载与打包优化
Vue前端还有一个需要重视的性能问题:首次加载后的资源体积。项目初期我把所有页面组件都直接 import 引入,首屏加载的JS包达到了1.8MB,在校园网环境下加载要好几秒。
解决办法是使用Vue Router的懒加载:
const routes = [ { path: '/', name: 'RecommendView', component: () => import('../views/recommend-view.vue') }, { path: '/restaurant/:id', name: 'RestaurantDetail', component: () => import('../views/restaurant-detail.vue') } ];改成() => import()动态导入后,首屏JS体积降到了380KB,页面加载速度提升了4倍。顺手还用 Vue CLI 自带的vue-cli-service build --report分析了打包依赖,发现echarts占了很大体积,于是改用按需引入只用到的雷达图和饼图组件,体积又降了一截。
7.5 一个隐蔽的Bug:时区问题导致的"实时"状态延迟
最后分享一个很隐蔽的坑。实时状态表里我存的是Timestamp类型,前端拿到时间后要格式化展示"5分钟前更新"这种时间差。但测试时发现,状态更新明明成功了,前端显示的时间差却总是不对——有时显示"刚刚更新",实际上已经是30分钟前。
排查最终定位到是时区配置不一致的问题。MySQL连接串里没有显式指定serverTimezone,Java后端和MySQL服务器使用了不同的时区解析逻辑。前端拿到的时间字符串被当成UTC时间解析了,显示出来的时间差自然不对。
解决办法是在JDBC连接串中显式指定时区:
spring.datasource.url=jdbc:mysql://localhost:3306/campus_food?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai这个问题不复杂,但排查过程花了不少时间。遇到时间显示异常,第一步永远先去检查时区配置。
做这个项目最大的收获是完整走了一遍"真实软件工程"的流程:需求分析、技术选型、架构设计、编码实现、性能调优、部署上线。每一环节都有大量的实际取舍——推荐算法不是越复杂越好,实时更新不是越快越好的道理,都是踩过坑才真正体会到的。
最后再分享一个建议:如果你也想做类似的校园推荐系统,不要一开始就想着造一个通用推荐平台,先想清楚你最了解的那个场景里,用户最痛的三个问题是什么。把这三个问题解决好,比堆砌十个看起来高级但用户根本不关心的功能有价值得多。松江大学城只是个起点,但这个项目的架构和核心逻辑,放到任何一个大学城、园区、甚至单位食堂,都能复用得起来。
本文还有配套的精品资源,点击获取
