做毕设的同学看到“SpringBoot+Vue 校园周边美食探索及分享平台”这种题目,第一反应往往是:这不就是一个普通的增删改查项目吗?但真正动手之后才发现,它把“地图定位”“内容分享”“社区互动”三件事揉在了一起,难度其实比想象中的要高不少。这套项目源码我已经完整跑通过,后端是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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;店铺表是美食平台的核心,除了名称、地址、电话这些基础字段,多了两个关键字段: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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;菜品表归属店铺,用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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;探店笔记表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 '图片URL,JSON数组', `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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;点赞表设计成用户和笔记的联合唯一索引防止重复点赞,收藏表通过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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;店铺评分表store_comment单独拎出来一张,记录用户对店铺的逐条评分和评价文字,店铺的平均评分在查询时动态聚合计算,不冗余存店铺表,保证数据一致性。2.3 SQL脚本里的数据设计经验项目附带的SQL脚本不只是建表,还分了三个层次:建库建表语句:上面这些CREATE TABLE,utf8mb4字符集,避免中文乱码基础字典数据:管理员账号、示例分类、少量示例店铺外键与索引:虽然用了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存到localStorage,axios请求拦截器把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;charset=UTF-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)); LambdaQueryWrapper<Shop> 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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分页查询不生效。这个高频问题大部分原因是没有配置分页插件拦截器。项目里需要在配置类注入PaginationInnerInterceptor:@Bean 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参数加characterEncoding=utf8后台管理页面白屏路由守卫拦截或token过期打开浏览器控制台查看网络请求状态码时间字段差8小时MySQL连接时区未设置serverTimezone=Asia/Shanghai实战给你的建议是:拿到任何项目源码后,第一件事不要急着启动,先把数据库脚本导入,把application.yml、request.js、vue.config.js三个配置文件全部过一遍,确认所有硬编码地址都被改成了你自己的环境地址。然后再启动,遇到问题先看控制台最后三行错误信息,而不是盯着整个堆栈看。项目跑通之后,强烈建议做三件事:第一,自己动手改一个页面样式,哪怕只是把列表改成卡片布局;第二,新增一个简单的后台“数据导出功能”;第三,把“附近美食”的距离算法手动调一下参数,体验数据里的热词“最新网络热点”如何影响算法权衡。这样在答辩时可以说“这是我扩展的功能”,展示出的项目深度会和单纯跑通源码完全不一样。我实际操作中还有一个心得:如果答辩电脑上不方便跑前后端两个进程,可以把前端build一次生成dist目录,然后把它复制到后端的resources/static目录下,这样SpringBoot就能同时提供接口和静态页面,部署时只启动一个Java进程即可。这个方案很简单,但能让你的现场演示稳定很多,不用多个终端来回切换。校园美食平台这种题目,最终比的就是谁能把细节讲清楚、把闭环跑通,能把这套流程走一遍,收获的不只是一个毕设分数。
做毕设的同学看到“SpringBoot+Vue 校园周边美食探索及分享平台”这种题目,第一反应往往是:这不就是一个普通的增删改查项目吗?但真正动手之后才发现,它把“地图定位”“内容分享”“社区互动”三件事揉在了一起,难度其实比想象中的要高不少。这套项目源码我已经完整跑通过,后端是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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;店铺表是美食平台的核心,除了名称、地址、电话这些基础字段,多了两个关键字段: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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;菜品表归属店铺,用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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;探店笔记表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 '图片URL,JSON数组', `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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;点赞表设计成用户和笔记的联合唯一索引防止重复点赞,收藏表通过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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;店铺评分表store_comment单独拎出来一张,记录用户对店铺的逐条评分和评价文字,店铺的平均评分在查询时动态聚合计算,不冗余存店铺表,保证数据一致性。2.3 SQL脚本里的数据设计经验项目附带的SQL脚本不只是建表,还分了三个层次:建库建表语句:上面这些CREATE TABLE,utf8mb4字符集,避免中文乱码基础字典数据:管理员账号、示例分类、少量示例店铺外键与索引:虽然用了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存到localStorage,axios请求拦截器把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;charset=UTF-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)); LambdaQueryWrapper<Shop> 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/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分页查询不生效。这个高频问题大部分原因是没有配置分页插件拦截器。项目里需要在配置类注入PaginationInnerInterceptor:@Bean 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参数加characterEncoding=utf8后台管理页面白屏路由守卫拦截或token过期打开浏览器控制台查看网络请求状态码时间字段差8小时MySQL连接时区未设置serverTimezone=Asia/Shanghai实战给你的建议是:拿到任何项目源码后,第一件事不要急着启动,先把数据库脚本导入,把application.yml、request.js、vue.config.js三个配置文件全部过一遍,确认所有硬编码地址都被改成了你自己的环境地址。然后再启动,遇到问题先看控制台最后三行错误信息,而不是盯着整个堆栈看。项目跑通之后,强烈建议做三件事:第一,自己动手改一个页面样式,哪怕只是把列表改成卡片布局;第二,新增一个简单的后台“数据导出功能”;第三,把“附近美食”的距离算法手动调一下参数,体验数据里的热词“最新网络热点”如何影响算法权衡。这样在答辩时可以说“这是我扩展的功能”,展示出的项目深度会和单纯跑通源码完全不一样。我实际操作中还有一个心得:如果答辩电脑上不方便跑前后端两个进程,可以把前端build一次生成dist目录,然后把它复制到后端的resources/static目录下,这样SpringBoot就能同时提供接口和静态页面,部署时只启动一个Java进程即可。这个方案很简单,但能让你的现场演示稳定很多,不用多个终端来回切换。校园美食平台这种题目,最终比的就是谁能把细节讲清楚、把闭环跑通,能把这套流程走一遍,收获的不只是一个毕设分数。