
做毕设的同学看到“SpringBootVue 校园周边美食探索及分享平台”这种题目第一反应往往是这不就是一个普通的增删改查项目吗但真正动手之后才发现它把“地图定位”“内容分享”“社区互动”三件事揉在了一起难度其实比想象中的要高不少。这套项目源码我已经完整跑通过后端是SpringBoot前端是Vue数据库脚本、接口文档、前端页面全部齐全。你拿到手不是光看而是能真正跑起来、能改、能答辩。接下来我按实际开发顺序把整个项目的设计思路、模块拆解、核心实现、部署过程、踩坑记录全部过一遍尽量让没做过完整项目的同学也能跟上节奏。1. 项目整体概览与选题价值分析1.1 这个项目到底是什么校园周边美食探索及分享平台拆开看是两句一是“探索”用户能按位置、品类发现周边的店铺和菜品二是“分享”用户自己能发布探店笔记、给店铺打分、写评论、收藏喜欢的店。它解决的问题很具体——大学生群体找吃的通常靠朋友口头推荐或在地图上漫无目的地翻缺少一个“围绕校园周边、由同龄人真实评价驱动”的聚合地。平台把店铺信息、地理位置、用户评价、社交分享整合到同一个系统里既有工具属性又有社区属性。从毕设角度说这个题目的定位很讨巧。它比纯电商项目轻又没有脱离前后端分离的主流技术栈比纯博客社区多了“地理位置附近推荐”这个有区分度的功能点能讲的东西明显更多。1.2 技术栈与项目结构后端技术栈非常主流SpringBoot 2.x第三方starter基本上都兼容MyBatis-Plus 做数据访问分页查询、条件构造器都是现成的JWT 无状态登录认证MySQL 8.x 数据库脚本里建库建表都有前端技术栈对应的是Vue 2 Element UI 组件库Vue Router 路由管理Vuex 状态管理负责用户登录状态Axios 统一封装请求ECharts 用于店铺评分分布等可视化图表前后端分离部署后端默认跑在8080端口前端用Vue CLI起在8081端口接口文档把所有RESTful API的路径、参数、返回结构都列清楚了这块对答辩非常有用。1.3 不同基础的同学能从中学到什么还有些同学可能会问“这项目和网上烂大街的商城系统有什么区别”区别就在“校园周边”这四个字上。商城系统的核心是商品SKU和订单状态机而这个项目的核心是“内容 地理坐标 用户关系”。商品SKU的逻辑是死的地理坐标筛选的SQL、社区内容的分页流、点赞收藏的关联表设计这些才是真正值得你反复琢磨的东西。如果你是零基础可以从这个项目里学会一个完整的前后端分离系统是怎么分层、怎么对接的。如果你有基础可以直接把“附近的人/附近的店”这套坐标计算逻辑迁移到任意LBS项目里这是通用技能不局限于毕设。2. 系统功能拆解与数据库设计2.1 用户侧与管理侧功能全景整个系统分为前台用户端和后台管理端前台是给普通用户用的后台用来维护基础数据。前台核心功能有七个模块用户注册登录基于JWT注册时密码通过BCrypt加密存储登录后前端持有token请求头里携带token访问受保护接口校园周边美食发现首屏展示地图和店铺列表支持按距离排序、按品类筛选点击店铺卡片可以跳转详情店铺详情页展示店铺环境图、招牌菜品图、营业时间、人均消费、位置坐标、综合评分以及该店收到的全部用户点评口碑评分系统用户可以对店铺打分1-5分系统自动聚合计算出店铺平均分评分和点评可修改可删除探店笔记社区用户发布图文笔记内容包含标题、正文、图片、关联店铺、位置标签支持点赞评论收藏个人中心展示我发布的笔记、我收藏的店铺、我的点评记录、个人资料编辑后台管理用户管理、店铺审核、笔记管理、评论管理、数据统计面板2.2 数据库表结构设计复盘整个项目数据量不大但表之间关系很典型建议直接按这个结构建。核心表一共八张-- 用户表 CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 登录账号, password varchar(100) NOT NULL COMMENT BCrypt加密后密码, nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像URL, email varchar(100) DEFAULT NULL, role tinyint(1) DEFAULT 0 COMMENT 0普通用户 1管理员, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY idx_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;店铺表是美食平台的核心除了名称、地址、电话这些基础字段多了两个关键字段longitude经度和latitude纬度后续做“附近推荐”全靠它们。另外还有status字段控制上下架审核审核通过才在前台展示。CREATE TABLE shop ( id int(11) NOT NULL AUTO_INCREMENT, shop_name varchar(100) NOT NULL, description varchar(500) DEFAULT NULL, address varchar(200) DEFAULT NULL, longitude decimal(10,6) DEFAULT NULL, latitude decimal(10,6) DEFAULT NULL, category varchar(50) DEFAULT NULL COMMENT 川菜/奶茶/火锅..., business_hours varchar(100) DEFAULT NULL, avg_price decimal(10,2) DEFAULT NULL COMMENT 人均消费, shop_image varchar(255) DEFAULT NULL, rating decimal(2,1) DEFAULT 0.0 COMMENT 综合评分, status tinyint(1) DEFAULT 0 COMMENT 0待审核 1已上架, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;菜品表归属店铺用shop_id关联菜品图片单独存URL不存二进制。CREATE TABLE dish ( id int(11) NOT NULL AUTO_INCREMENT, shop_id int(11) NOT NULL, dish_name varchar(100) NOT NULL, price decimal(10,2) DEFAULT NULL, dish_image varchar(255) DEFAULT NULL, description varchar(255) DEFAULT NULL, PRIMARY KEY (id), KEY idx_shop_id (shop_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;探店笔记表article是整个社区内容的载体字段包括标题、正文、关联店铺ID、图片JSON数组字符串存储、发表用户ID、点赞数、评论数、浏览数。评论区 comment 表通过article_id关联笔记。CREATE TABLE article ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, shop_id int(11) DEFAULT NULL, title varchar(100) NOT NULL, content text, images varchar(1000) DEFAULT NULL COMMENT 图片URLJSON数组, location varchar(100) DEFAULT NULL, like_count int(11) DEFAULT 0, comment_count int(11) DEFAULT 0, view_count int(11) DEFAULT 0, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id), KEY idx_shop_id (shop_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;点赞表设计成用户和笔记的联合唯一索引防止重复点赞收藏表通过target_type字段区分收藏的是店铺还是笔记这样一张表能复用两种收藏场景。CREATE TABLE like_record ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, article_id int(11) NOT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_article (user_id,article_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE favorite ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, target_type tinyint(1) DEFAULT 0 COMMENT 0店铺 1笔记, target_id int(11) NOT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;店铺评分表store_comment单独拎出来一张记录用户对店铺的逐条评分和评价文字店铺的平均评分在查询时动态聚合计算不冗余存店铺表保证数据一致性。2.3 SQL脚本里的数据设计经验项目附带的SQL脚本不只是建表还分了三个层次建库建表语句上面这些CREATE TABLEutf8mb4字符集避免中文乱码基础字典数据管理员账号、示例分类、少量示例店铺外键与索引虽然用了MyBatis-Plus但表设计里索引不要省like_count、comment_count这些高频查询字段必须有索引我当时导入脚本时踩过一个坑MySQL 8默认的utf8mb4_0900_ai_ci排序规则在旧版Navicat里可能识别异常建议直接用命令行或新版客户端执行整体脚本不要分段复制粘贴避免漏掉建表语句。3. 后端架构与核心接口的完整实现3.1 后端分层结构与JWT鉴权链路后端代码采用标准分层架构Controller接收请求Service处理业务逻辑Mapper操作数据库实体类对应表结构。项目里有一个比较重要的设计返回结果统一封装成Result对象包含code、message、data三个字段前端axios响应拦截器统一处理。这样接口文档里不需要解释太多前端对后端的约定非常清晰。JWT鉴权链路是这类项目的必考题。实际实现中前端登录成功后拿到token存到localStorageaxios请求拦截器把token加到请求头的Authorization字段。后端用一个拦截器HandlerInterceptor拦截需要认证的接口从请求头取出token并解析。解析成功就把用户ID放到ThreadLocal里供后续业务使用解析失败直接返回401状态码。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行OPTIONS预检请求 if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { token token.substring(7); // 解析token校验签名和过期时间 Integer userId JwtUtil.parseToken(token); if (userId ! null) { UserContext.set(userId); return true; } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\登录状态已过期\}); return false; } }这段拦截器代码并不复杂但它是整个后端安全体系的地基。如果你不想用Spring Security那套复杂的过滤器链这个轻量拦截器对毕设来说完全够用只要记得放行登录注册接口和店铺列表等公开接口。3.2 校园周边店铺发现经纬度筛选是怎么算的这个项目最有区分度的功能就是“校园周边”核心是拿到用户的当前位置坐标然后找出该坐标周边N公里内的店铺。常见的实现有两种在数据库里计算两坐标点距离用Haversine公式先粗筛经纬度范围再用Java计算精确距离项目里用的是第二种思路因为第一种如果数据量大了全表扫描计算距离的性能会很差。粗筛逻辑可以用MyBatis-Plus的条件构造器实现// 纬度每度约111km经度每度距离随纬度变化 double latDelta radiusKm / 111.0; double lngDelta radiusKm / (111.0 * Math.cos(latitude * Math.PI / 180)); LambdaQueryWrapperShop wrapper Wrappers.lambdaQuery(Shop.class) .between(Shop::getLatitude, latitude - latDelta, latitude latDelta) .between(Shop::getLongitude, longitude - lngDelta, longitude lngDelta) .eq(Shop::getStatus, 1);SQL脚本里我建议给longitude和latitude建联合索引因为这种范围查询是核心场景。粗筛出候选集合之后再逐条计算实际距离过滤掉超出半径范围的再按距离从近到远排序返回给前端。如果你不想自己写Haversine公式可以直接用MySQL内置的ST_Distance_Sphere函数但需要注意它要求字段是POINT类型建表时得提前规划好项目里用的decimal方式更通用一些。3.3 推荐逻辑实现评分聚合与热榜排序首页推荐和店铺列表不能只按创建时间倒序排那样新店会一直霸榜老店口碑再好也上不了首页。项目里的推荐排序算法是一个加权公式// 综合评分权重0.7热度权重0.3 double sortScore shop.getRating() * 0.7 log10(shop.getViewCount() 1) * 1.5;实际代码里为了避免数值差距过大热度部分要用log函数压缩量级否则访问量几百的店会把9分高评店挤下去。这个细节答辩时提一句老师会认为你真考虑过算法合理性。店铺详情页的评分聚合用group by实现一条SQL就能统计出评分分布。菜品推荐则基于店铺内的销量字段排序没做复杂的协同过滤因为毕设阶段手工维护测试数据要比实现一个假推荐算法更可靠。3.4 分享社区闭环发帖、点赞、评论的并发一致性发布笔记接口的逻辑相对常规文件上传部分是把图片保存到本地目录然后把拼接好的URL写入库。需要注意的一点是图片保存目录不能是项目临时目录要配置成固定磁盘路径同时把该路径映射成静态资源URL否则项目重启后上传的图片全部丢失。Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath System.getProperty(user.dir) File.separator upload File.separator; registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); }点赞接口是一个“先检查后插入”的典型场景两个用户同时点赞可能造成并发问题。MyBatis-Plus里最简单的做法是依赖like_record表的联合唯一索引插入时捕获DuplicateKeyException捕获到就说明已经点过赞。同时更新article表的like_count用原子自增SQL避免先查后改丢数据UPDATE article SET like_count like_count 1 WHERE id #{articleId}评论和收藏接口同理核心就是先查后写要加锁或加唯一约束一旦有唯一约束兜底并发问题在毕设这个量级下基本不用额外处理。4. 前端Vue实现与页面交互4.1 Vue项目结构和样式基础前端项目用Vue CLI创建目录结构按views、components、router、store、utils拆开。重点在router里加了路由守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requireAuth !token) { next(/login) } else { next() } })有些页面如发布笔记、个人中心必须登录才能访问路由meta里标记requireAuth为true。接口请求封装在src/utils/request.js里基于axios实例创建统一设置baseURL和超时时间响应拦截器里遇到code为401时自动跳转登录页。service.interceptors.response.use( response { const res response.data if (res.code ! 200) { if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.message)) } return res }, error { return Promise.reject(error) } )前端的页面风格整体走清爽路线主色调用偏暖的橙色系和美食主题匹配。Element UI的Carousel组件做首页轮播图Rate组件做店铺评分展示Map组件用静态地图图片加标记点模拟没有接第三方地图SDK降低了部署复杂度。4.2 首页发现页与店铺详情页首页分三个区块顶部分类筛选条中间热门店铺海报轮播下方是附近店铺列表。分类筛选点击后触发接口请求携带category参数。附近店铺列表用了无限滚动加载监听窗口滚动到列表底部时自动加载下一页。店铺详情页核心是信息头和评论区。信息头展示店铺图、名称、评分、人均、地址用一个Rating星星组件实时展示平均分。评论区底部是评分组件加文字评价提交评价后前端先把评价插入到列表顶部同时重新拉取总和评分这样用户感知的反馈速度很快。4.3 发帖流程与图片上传细节发布笔记页是内容型的核心界面。标题输入框、富文本正文、图片上传组件、关联店铺搜索器四个部分组合起来。图片上传这里有个容易被忽略的坑Element UI的el-upload组件默认用Ajax提交如果后端接口返回的是自定义Result结构组件会误认为格式不符合标准而报错。正确做法是把auto-upload设为false用手动上传方式拿到后端返回的图片URL后push进文件列表再随表单数据一起提交handleImageChange(file) { const formData new FormData() formData.append(file, file.raw) uploadImage(formData).then(res { this.images.push(res.data.url) }) }店铺搜索器用远程搜索模式输入关键字后请求店铺接口下拉框展示搜索结果选择后把shop_id存进表单。这个交互细节使前端和后端贴合度更高也是答辩时一个可说的亮点。4.4 后台管理页面后台管理用单独一套布局分为数据概览、用户管理、店铺管理、笔记管理四个子页面。数据概览页引入ECharts图表展示每日新增用户数和评分分布柱状图。店铺管理里采用表格加行内审核按钮待审核店铺用红色标签标记管理员点通过或驳回。这一页完全复用前端的axios封装只是调用的接口路径带了/admin前缀后端在拦截器里额外校验了管理员角色权限。5. 从零到一项目部署与典型问题排查5.1 环境准备与配置调整项目运行所需的开发环境非常标准JDK 1.8、Maven 3.6、MySQL 8.0、Node.js 14。拿到项目压缩包后按4个步骤完成启动前的配置准备。第一步把sql目录下的脚本导入数据库建议建一个独立数据库名称比如campus_food导入完成后检查user表有没有初始化管理员账号。第二步修改application.yml文件里的数据库账号密码以及文件上传路径spring: datasource: url: jdbc:mysql://localhost:3306/campus_food?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 servlet: multipart: max-file-size: 10MB max-request-size: 50MB file: upload-path: /data/campus_food/upload/第三步前端在src/utils/request.js里改baseURL指向后端地址const service axios.create({ baseURL: http://localhost:8080/api, timeout: 15000 })第四步后端启动前确认Maven依赖能拉到因为国内网络环境问题如果第一次mvn package下载依赖太慢可以把镜像源换成阿里云。5.2 项目启动顺序与验证启动顺序有讲究先启动MySQL再启动后端SpringBoot最后启动前端Vue开发服务器。后端在IDE里直接运行主类看到控制台打印出“Tomcat started on port 8080”表示成功。前端在项目根目录执行npm install安装依赖然后npm run serve启动开发服务器控制台会输出访问地址。环境全部启动后先用接口文档测一遍登录接口是否正常用POST请求提交账号密码返回token表示后端连通。然后浏览器访问前端页面注册一个用户发布一条探店笔记并上传图片确认图片能正常回显。最后用管理员账号登录后台管理审核一下刚才注册的用户或者发布的笔记闭环验证整个系统跑通。5.3 高频问题排查实录问题一启动后端时报错Access denied for user root。这是数据库账号密码配置错误检查application.yml里的username和password是否和本地MySQL一致。尤其注意root密码不要在配置文件里用特殊字符容易在yaml解析阶段出问题。问题二前端请求接口报跨域错误。前后端分离项目必须处理跨域检查后端是否配置了跨域过滤器。项目里用一个WebMvcConfigurer实现跨域支持Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); }配置完成后仍然跨域就要检查是不是请求路径写错了baseURL带了/api但Controller的RequestMapping也带了/api导致实际路径变成了/api/api/xxx。问题三注册时提示密码长度不足60。BCrypt加密后的密文长度是60位如果数据库password字段长度建小了会直接报错按照脚本里的varchar(100)就不会有这个问题。问题四MyBatis-Plus分页查询不生效。这个高频问题大部分原因是没有配置分页插件拦截器。项目里需要在配置类注入PaginationInnerInterceptorBean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }没配置分页插件时page方法返回的total一直是0列表也显示不完整。问题五Vue项目npm install报错。如果出现ERR_SOCKET_TIMEOUT或ENOTFOUND八成是npm镜像源的问题执行npm config set registry https://registry.npmmirror.com后重新install即可。6. 常见问题速查表与实战建议现象可能原因解决方案前端能访问但接口404请求路径拼接重复或接口文档与代码不一致对照Controller类的RequestMapping完整路径检查axios请求地址登录成功后刷新页面就退出token只存在内存没持久化登录成功后存localStorage每次请求拦截器自动附加上传图片后访问返回404静态资源映射路径没有生效检查addResourceHandlers的磁盘路径与配置是否一致点赞后数字没变化乐观锁冲突或缓存未更新改用like_count 1原子更新SQL中文乱码数据库连接字符集错误URL参数加characterEncodingutf8后台管理页面白屏路由守卫拦截或token过期打开浏览器控制台查看网络请求状态码时间字段差8小时MySQL连接时区未设置serverTimezoneAsia/Shanghai实战给你的建议是拿到任何项目源码后第一件事不要急着启动先把数据库脚本导入把application.yml、request.js、vue.config.js三个配置文件全部过一遍确认所有硬编码地址都被改成了你自己的环境地址。然后再启动遇到问题先看控制台最后三行错误信息而不是盯着整个堆栈看。项目跑通之后强烈建议做三件事第一自己动手改一个页面样式哪怕只是把列表改成卡片布局第二新增一个简单的后台“数据导出功能”第三把“附近美食”的距离算法手动调一下参数体验数据里的热词“最新网络热点”如何影响算法权衡。这样在答辩时可以说“这是我扩展的功能”展示出的项目深度会和单纯跑通源码完全不一样。我实际操作中还有一个心得如果答辩电脑上不方便跑前后端两个进程可以把前端build一次生成dist目录然后把它复制到后端的resources/static目录下这样SpringBoot就能同时提供接口和静态页面部署时只启动一个Java进程即可。这个方案很简单但能让你的现场演示稳定很多不用多个终端来回切换。校园美食平台这种题目最终比的就是谁能把细节讲清楚、把闭环跑通能把这套流程走一遍收获的不只是一个毕设分数。