做毕设最怕什么?不是不会写代码,而是老师验收时问一句:“这个项目里最难的点在哪?你改了哪些设计?”你支支吾吾答不上来,只能反复说“用了 Spring Boot + Vue + MySQL”。
校园食堂点评系统就是这一类的典型题目。听上去它只是普通的管理系统,无非是把“图书管理”换成“食堂窗口管理”,再套一层登录。但如果你真去拆需求,会发现它比图书管理多了一个非常真实的产品核心:评分、匿名、审核和统计。
这篇文章不会贴一个完整的整仓库源码让你直接下载,而是教你拿到这类源码之后,怎么把项目跑起来、怎么理解它的核心表结构、怎么看懂后端和前端的关键代码、怎么回答答辩老师的追问。如果你打算自己从零写一个,这套拆解同样可以直接落地。
我的核心判断是:食堂点评系统的难点不在增删改查,而在“一个评分从用户提交到前台展示,中间经历了什么”。把这个流程讲透,你的毕业设计就已经赢了一半。
1. 选题分析:食堂点评系统适合什么样的读者
先聊选题值不值。
很多计算机专业的学生会避开“点评”“论坛”“社区”类题目,觉得要实现评论互动、敏感词、匿名、审核,工作量比传统的单表 CRUD 大。但实际上,这恰恰是这类题目比“XX管理系统”更有竞争力的原因。
传统的管理系统,页面是:登录、列表、新增、编辑、删除。答辩时十分钟讲完,老师其实很难找到有价值的问题。
食堂点评系统则天然具备三个可以被追问的设计点:
- 匿名点评:用户选了匿名之后,前端展示时怎么隐藏用户名?但管理员后台如何仍然能追溯?
- 评分设计:口味、卫生、服务分别打分,最终展示的综合分是怎么算出来的?要不要支持不同权重?
- 内容审核:新提交的点评不应该立即出现在前台,管理员审核通过之后才公开展示,那窗口的平均分什么时候更新?
如果你把这三个点想清楚了,代码写明白,答辩时基本是在带着老师看设计,而不是被老师问倒。
再说说技术栈。Spring Boot 负责提供 RESTful API,Vue 负责页面交互,MySQL 负责数据持久化,这是目前高校毕业设计里最主流的一套组合,网上资料多、排错成本低,找参考时也不容易卡住。
如果你拿到的是 Vue 2 的版本,也没关系,下面大部分页面逻辑是一样的,差异主要集中在路由初始化和 Element UI 的使用方式上。本文示例偏向 Vue 3 + Element Plus,但思路完全通用。
2. 系统模块划分:不要一上来就写代码
源码拿到手,第一步不是看代码,而是先看项目结构,弄清楚这个系统被拆分成了哪些模块。
一个标准的校园食堂点评系统,按角色划分大致如下:
| 角色 | 核心诉求 | 对应功能 |
|---|---|---|
| 学生 | 查食堂、看评分、表达真实用餐体验 | 注册、登录、浏览窗口、发布点评、匿名点评、查看个人点评 |
| 食堂窗口 | 了解自家评分、看学生反馈 | 查看点评、查看评分统计,部分系统会提供窗口管理入口 |
| 系统管理员 | 保证内容真实、平台有序 | 用户管理、窗口审核、点评审核、公告管理、数据统计 |
对毕设而言,最简单的角色划分就是两类:普通学生和管理员。窗口经营者不需要独立登录,因为那会牵扯到新的角色表和权限逻辑,增加的工作量大于答辩收益。
核心业务闭环可以这样理解:
学生注册登录 -> 浏览食堂窗口 -> 对用餐窗口打分并提交文字点评 -> 点评进入待审核状态 -> 管理员审核通过 -> 前台可见 -> 窗口平均分与统计图表同步更新。
这里面最容易被忽略的是“待审核状态”。很多新手会把点评直接插入数据库、直接展示出来,这个流程虽然也能跑通,但缺少业务深度。现实中,任何 UGC 内容都需要审核,点评系统更不例外。“审核后再展示”才能体现内容管理的设计意识。
3. 数据库设计:核心不是“能存下”,而是“能算得对”
数据库是这类系统的灵魂。你说你会 Spring Boot、会 Vue,但表结构一展开,老师基本就能看出你的设计水平。
3.1 核心表拆解
食堂点评系统至少需要四张核心表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| sys_user | 用户表,包含学生和管理员 | username、password、role、status |
| canteen_window | 食堂窗口表 | name、canteen_name、manager、avg_score、review_count |
| review_record | 点评记录表 | user_id、window_id、taste_score、hygiene_score、service_score、anonymous、status |
| menu_item | 窗口菜单表,用于展示窗口的菜品信息 | window_id、name、price、image |
可能有的系统里还会加一张公告表,或者把角色单独抽成一张表。这里只讨论最小可用模型,你先把它跑通,再按源码或自己需求扩展。
3.2 为什么点评表和窗口表会有关键设计陷阱
重点说 review_record 表。
一个点评系统最常见的错误,是只给点评表留一个总分字段,比如final_score。前端让用户打个分,后端存个数字就完了。这个做法当场就能实现,但老师一问“为什么只打一个总分,这样的评价维度是不是太粗”,你就很难自圆其说。
我的建议是参考真实点评产品的做法,把评分拆成几个小维度。食堂场景最合理的是三维度评分:
- taste_score:口味评分
- hygiene_score:卫生评分
- service_score:服务评分
展示综合分时,可以取三个维度的平均值,也可以给不同维度配权重。比如口味占 40%,卫生占 40%,服务占 20%。毕设里用简单平均即可,但如果你实现了权重配置,反而是一个可以写进论文的亮点。
之所以不用单总分,还有一个逻辑上的好处:你可以在列表页提供“按口味排序”“按卫生排序”的扩展,也可以后续再加上维度字段而不用重构评分逻辑。
下面给出一份可以直接执行的 MySQL 建表脚本:
CREATE DATABASE IF NOT EXISTS canteen_review DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE canteen_review; CREATE TABLE `sys_user` ( `id` bigint(20) 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 '头像地址', `role` varchar(20) NOT NULL DEFAULT 'STUDENT' COMMENT '角色:ADMIN / STUDENT', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用', `deleted` tinyint(4) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表'; CREATE TABLE `canteen_window` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '窗口名称', `canteen_name` varchar(100) DEFAULT NULL COMMENT '所属食堂,如一食堂、二食堂', `description` varchar(500) DEFAULT NULL COMMENT '窗口简介', `image` varchar(255) DEFAULT NULL COMMENT '窗口图片', `manager` varchar(50) DEFAULT NULL COMMENT '窗口负责人', `avg_score` decimal(3,2) NOT NULL DEFAULT '0.00' COMMENT '综合评分,冗余字段', `review_count` int(11) NOT NULL DEFAULT '0' COMMENT '已通过点评数量', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1展示 0下架', `deleted` tinyint(4) NOT NULL DEFAULT '0', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='食堂窗口表'; CREATE TABLE `review_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '点评人ID', `window_id` bigint(20) NOT NULL COMMENT '被点评窗口ID', `content` varchar(1000) DEFAULT NULL COMMENT '文字点评内容', `taste_score` tinyint(4) NOT NULL DEFAULT '0' COMMENT '口味评分,1-5', `hygiene_score` tinyint(4) NOT NULL DEFAULT '0' COMMENT '卫生评分,1-5', `service_score` tinyint(4) NOT NULL DEFAULT '0' COMMENT '服务评分,1-5', `anonymous` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否匿名:0否 1是', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0待审核 1已通过 2已驳回', `reject_reason` varchar(200) DEFAULT NULL COMMENT '驳回原因', `deleted` tinyint(4) NOT NULL DEFAULT '0', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_window_id` (`window_id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='点评记录表'; CREATE TABLE `menu_item` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `window_id` bigint(20) NOT NULL COMMENT '所属窗口ID', `name` varchar(100) NOT NULL COMMENT '菜品名称', `price` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '价格', `image` varchar(255) DEFAULT NULL COMMENT '菜品图片', `recommended` tinyint(4) NOT NULL DEFAULT '0' COMMENT '是否为推荐菜', `deleted` tinyint(4) NOT NULL DEFAULT '0', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='窗口菜单表';这里再解释两个容易被忽略的设计点。
第一,点评表没有设计总评分数字段。综合分通过taste_score + hygiene_score + service_score计算得到,不要在数据库里冗余一个重复字段。如果你一定要在列表页快速排序,那也应该是窗口表里的avg_score担当,而不是点评表里的总评分。
第二,窗口表里的avg_score是冗余字段。常规设计里,需要展示窗口平均分时,直接查点评表求 AVG 也可以,但每次列表页都做一次子查询会显得不够优雅。于是把平均分冗余到窗口表中,点评审核通过后同步更新一次。
这个字段可以让老师觉得你理解了“读多写少”的性能优化思路。但也要意识到,冗余字段维护不好就会脏数据,所以后台模块必须把“重算窗口平均分”封装成一个独立方法,在审核通过、驳回、删除点评时都调用它。
3.3 菜单表有什么作用
很多同学会把菜单直接写死在窗口描述里,这非常不利于后面的扩展。单独建一张 menu_item 表后,食堂窗口就可以在前台或后台展示菜品,窗口详情页也不至于只有一句话。
对毕设答辩而言,窗口详情页是给老师演示的最佳页面。你可以展示窗口图片、菜单列表、历史点评、评分分布四个区块。菜单表的存在让页面内容更饱满,不显得单薄。
4. 后端 Spring Boot:核心代码怎么看
Spring Boot 后端最忌讳的就是 Controller 里写了一大堆业务逻辑。拿到源码后,先看包结构,如果里面包含清晰的 controller、service、mapper、entity、config 分层,这个项目的质量通常不会太差。
4.1 典型包结构
com.example.canteen ├── CanteenApplication.java ├── config │ ├── CorsConfig.java │ ├── JwtInterceptor.java │ └── MybatisPlusConfig.java ├── controller │ ├── AuthController.java │ ├── WindowController.java │ └── ReviewController.java ├── dto │ ├── LoginDTO.java │ └── ReviewSubmitDTO.java ├── entity │ ├── User.java │ ├── CanteenWindow.java │ └── ReviewRecord.java ├── mapper │ ├── UserMapper.java │ ├── CanteenWindowMapper.java │ └── ReviewRecordMapper.java ├── service │ ├── ReviewService.java │ └── impl │ └── ReviewServiceImpl.java └── common ├── Result.java └── ServiceException.java如果你的项目还多出 vo、utils、security 这类包,也很正常。关键是看到代码时能快速说出每个类的作用。
4.2 后端核心链路:点评是怎么被提交的
点评提交是系统的核心流程。先看 Controller:
// 文件路径:com.example.canteen.controller.ReviewController.java @RestController @RequestMapping("/api/review") public class ReviewController { @Resource private ReviewService reviewService; /** * 提交点评,点评默认状态为待审核 */ @PostMapping("/submit") public Result<Void> submit(@RequestBody ReviewSubmitDTO dto) { reviewService.submitReview(dto); return Result.success(); } /** * 管理员分页查询点评 */ @GetMapping("/admin/page") public Result<Page<ReviewRecord>> adminPage(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "10") int size, @RequestParam(required = false) Integer status) { return Result.success(reviewService.adminPage(page, size, status)); } /** * 管理员审核点评 */ @PostMapping("/admin/audit/{id}") public Result<Void> audit(@PathVariable Long id, @RequestParam Integer status, @RequestParam(required = false) String rejectReason) { reviewService.auditReview(id, status, rejectReason); return Result.success(); } }这里的ReviewSubmitDTO是前端提交过来的对象,里面包含窗口 ID、三个评分、点评内容和是否匿名。注意不要让前端直接把 user_id 传过来,这是一个安全设计习惯。当前登录用户应该从 Token 里解析获取,而不是信任客户端参数。
再看 Service 层。真正值得细读的是这部分:
// 文件路径:com.example.canteen.service.impl.ReviewServiceImpl.java @Service public class ReviewServiceImpl implements ReviewService { @Resource private ReviewRecordMapper reviewRecordMapper; @Resource private CanteenWindowMapper canteenWindowMapper; @Override @Transactional(rollbackFor = Exception.class) public void submitReview(ReviewSubmitDTO dto) { Long userId = LoginUserHolder.getUserId(); // 判断用户是否已经点评过该窗口 Long count = reviewRecordMapper.selectCount( new LambdaQueryWrapper<ReviewRecord>() .eq(ReviewRecord::getUserId, userId) .eq(ReviewRecord::getWindowId, dto.getWindowId()) .in(ReviewRecord::getStatus, 0, 1) ); if (count != null && count > 0) { throw new ServiceException("您已经点评过该窗口,请勿重复提交"); } // 校验分数范围,避免前端传 0 或 100 之类的非法值 checkScore(dto.getTasteScore()); checkScore(dto.getHygieneScore()); checkScore(dto.getServiceScore()); // 创建点评记录,状态默认为待审核 ReviewRecord record = new ReviewRecord(); record.setUserId(userId); record.setWindowId(dto.getWindowId()); record.setContent(dto.getContent()); record.setTasteScore(dto.getTasteScore()); record.setHygieneScore(dto.getHygieneScore()); record.setServiceScore(dto.getServiceScore()); record.setAnonymous(dto.getAnonymous() != null && dto.getAnonymous() ? 1 : 0); record.setStatus(0); reviewRecordMapper.insert(record); } @Override @Transactional(rollbackFor = Exception.class) public void auditReview(Long id, Integer status, String rejectReason) { ReviewRecord record = reviewRecordMapper.selectById(id); if (record == null) { throw new ServiceException("点评不存在"); } record.setStatus(status); if (status == 2) { record.setRejectReason(rejectReason); } reviewRecordMapper.updateById(record); // 审核通过或驳回,都需要重算该窗口的平均分,确保统计结果一致 recalcWindowScore(record.getWindowId()); } /** * 重算某个窗口的平均分,只统计审核通过的点评 */ private void recalcWindowScore(Long windowId) { List<ReviewRecord> passedList = reviewRecordMapper.selectList( new LambdaQueryWrapper<ReviewRecord>() .eq(ReviewRecord::getWindowId, windowId) .eq(ReviewRecord::getStatus, 1) ); if (passedList.isEmpty()) { canteenWindowMapper.update(null, new LambdaUpdateWrapper<CanteenWindow>() .eq(CanteenWindow::getId, windowId) .set(CanteenWindow::getAvgScore, 0.00) .set(CanteenWindow::getReviewCount, 0)); return; } BigDecimal total = BigDecimal.ZERO; for (ReviewRecord record : passedList) { BigDecimal scoreAvg = BigDecimal.valueOf(record.getTasteScore()) .add(BigDecimal.valueOf(record.getHygieneScore())) .add(BigDecimal.valueOf(record.getServiceScore())) .divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP); total = total.add(scoreAvg); } BigDecimal avg = total.divide(BigDecimal.valueOf(passedList.size()), 2, RoundingMode.HALF_UP); canteenWindowMapper.update(null, new LambdaUpdateWrapper<CanteenWindow>() .eq(CanteenWindow::getId, windowId) .set(CanteenWindow::getAvgScore, avg) .set(CanteenWindow::getReviewCount, passedList.size())); } }这里的几个细节,就是你答辩时能讲的东西:
- 加了
@Transactional(rollbackFor = Exception.class),保证点评插入和窗口评分更新同时成功或同时失败。 - 重复点评的判断放在 Service 层,并且只拦截状态为待审核和已通过的记录。如果学生上次点评被管理员驳回了,可以重新提交,这个逻辑比简单的一刀切更合理。
- 窗口平均分不是在前台查询时临时计算,而是在审核动作之后重算,保证展示分数和审核数据一致。
如果你发现源码里没有recalcWindowScore,只是把总评分存到一个字段里,也不需要慌张。只要理解了上面这段代码想解决的问题,你会自己在源码里改出一版更合理的设计,这本身就已经是加分项。
4.3 后端配置:MySQL 连接与 MyBatis-Plus
绝大多数校园食堂点评系统源码都会使用 MyBatis-Plus,因为它能显著减少 Mapper 层的样板代码。如果你手里项目的pom.xml里没有 MyBatis-Plus,而是使用了 Spring Data JPA,代码会稍有不同,但 service 的业务流程依旧一致。
配置文件示例:
# 文件路径:src/main/resources/application.yml server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/canteen_review?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 # 如果使用的是 MySQL 5.x 且驱动版本较低,driver-class-name 可能是 com.mysql.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 logging: level: com.example.canteen.mapper: debug连接串里的serverTimezone=Asia/Shanghai和characterEncoding=utf8大概率能帮你避免中文乱码和日期差八小时的问题。allowPublicKeyRetrieval=true是 MySQL 8 下常见的一个连接配置,不加的话有时会报错。
另外,MyBatis-Plus 的逻辑删除字段如果不做配置,实体里的deleted字段就会被当成普通字段处理。上面的配置已经把逻辑删除开关打开了,这样执行deleteById时,实际执行的是UPDATE ... SET deleted = 1。要注意,逻辑删除生效后,所有查询都会自动带上deleted = 0条件。
5. Vue 前端:页面结构与关键交互
前端这部分,你不需要背下每一行代码。重点理解三件事:路由对应哪些页面、请求是怎么发出去的、点评表单里哪些交互会直接影响后端展示。
5.1 页面与路由
典型的 Vue 页面结构如下:
src ├── api │ ├── request.js │ └── review.js ├── router │ └── index.js ├── views │ ├── Login.vue │ ├── Register.vue │ ├── Home.vue │ ├── WindowDetail.vue │ ├── MyReview.vue │ └── admin │ ├── UserManage.vue │ ├── WindowManage.vue │ └── ReviewManage.vue └── components ├── ScoreRate.vue └── ReviewCard.vue整体逻辑是:
- 未登录用户访问
/,显示食堂窗口列表。 - 点击某个窗口进入
/window/:id,看到窗口详情、菜单、历史点评。 - 登录用户可以提交点评。
- 管理员访问
/admin/review,对点评进行审核管理。
5.2 统一请求封装
前端所有接口请求建议走统一封装。这里以 axios 为例:
// 文件路径:src/api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:把登录后拿到的 token 放到请求头 request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) // 响应拦截器:统一处理异常 request.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) } return res }, error => { if (error.response && error.response.status === 401) { ElMessage.error('登录已过期,请重新登录') localStorage.removeItem('token') router.push('/login') } else { ElMessage.error('网络异常,请稍后重试') } return Promise.reject(error) } ) export default request这个封装几乎是所有 Vue 毕设项目的标准做法。一方面避免了每个页面重复写axios.get时的拦截逻辑,另一方面也统一了后端返回的{ code, msg, data }结构。
5.3 匿名点评前台怎么处理
这里有一个很容易被忽略的细节:匿名点评只是在页面上不显示用户名,但数据库依然要存user_id,因为管理员在后台审核时可能需要知道这条点评是谁发的。如果数据库把 user_id 也置空了,管理员就无法追溯恶意或违规内容。
前端组装点评卡片时,判断逻辑类似下面这样:
<!-- 文件路径:src/components/ReviewCard.vue,匿名展示控制片段 --> <template> <div class="review-card"> <div class="review-header"> <span v-if="review.anonymous === 1" class="reviewer"> 匿名用户 </span> <span v-else class="reviewer"> {{ review.nickname || review.username }} </span> <span class="review-time">{{ review.createTime }}</span> </div> <div class="review-score"> <el-rate :model-value="review.avgScore" disabled /> </div> <p class="review-content">{{ review.content }}</p> </div> </template>后端查询点评列表时,要处理好是否返回用户昵称。
如果用户选择了匿名,后端在返回给前台的 DTO/VO 里,就不应该把username填上真实昵称,而是统一填“匿名用户”。不要在实体类上直接把user字段全量序列化出去,否则匿名点评对普通学生来说就形同虚设。
5.4 评分组件与点评提交页面
学生提交点评的页面,应该用星级评分组件来做。Element Plus 里的el-rate就是一个现成的组件:
<!-- 文件路径:src/views/WindowDetail.vue 中提交点评的部分 --> <template> <el-card class="submit-card" v-if="isLogin"> <h3>发表你的评价</h3> <div class="rate-item"> <span>口味</span> <el-rate v-model="form.tasteScore" show-score /> </div> <div class="rate-item"> <span>卫生</span> <el-rate v-model="form.hygieneScore" show-score /> </div> <div class="rate-item"> <span>服务</span> <el-rate v-model="form.serviceScore" show-score /> </div> <el-input v-model="form.content" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="说说这家窗口的菜品、分量、出餐速度吧" /> <div class="submit-footer"> <el-checkbox v-model="form.anonymous">匿名发布</el-checkbox> <el-button type="primary" @click="submitReview">发布点评</el-button> </div> </el-card> </template> <script setup> import { reactive } from 'vue' import request from '@/api/request' import { ElMessage } from 'element-plus' const form = reactive({ windowId: route.params.id, tasteScore: 5, hygieneScore: 5, serviceScore: 5, content: '', anonymous: false }) async function submitReview() { const res = await request.post('/review/submit', form) if (res.code === 200) { ElMessage.success('提交成功,等待审核') form.content = '' } } </script>这类页面唯一的坑是表单校验不完整。前端只做展示拦截,真正的比分范围校验一定要在后端做一遍,不然把tasteScore传成 5.5 或 100,后台计算平均分就会出现匪夷所思的结果。
6. 完整运行流程:拿到源码后先跑通
无论你是从网上下载的源码,还是自己跟着教程敲的代码,第一次运行之前都要确认顺序。
6.1 前置环境
如果你完全从零开始,需要先安装:
- JDK 1.8 或更高版本
- Maven 3.x
- MySQL 5.7 或 8.x
- Node.js 14 或更高版本
- Navicat、DataGrip、IDEA 等数据库或 IDE 工具
具体版本以你手里源码的pom.xml和前端package.json要求为准。这里不写死版本号,是因为网上流传的源码差别比较大。拿到源码后,第一件事就是打开这两个文件确认版本要求,比直接运行更稳妥。
6.2 导入数据库
在项目目录下找到.sql文件,一般放在sql/或db/目录下。
使用 Navicat 连接本地 MySQL,新建查询,执行整个 SQL 文件;或者直接在命令行导入:
mysql -uroot -p < sql/canteen_review.sql如果 SQL 文件里包含DROP TABLE IF EXISTS语句,执行前务必确认当前数据库没有重要数据。开发环境无所谓,但如果库里已有数据,会被直接清空。
导入完成后,查看一下三张关键表的数据,尤其是用户表里有没有admin账号。如果没有,就需要先通过前端注册接口创建一个普通账号,然后把数据库中对应的role改为ADMIN,或者执行源码自带的初始化数据脚本。
6.3 启动后端
在 IDEA 中打开后端目录,等待 Maven 拉取依赖。如果网络不佳导致依赖下载失败,可以配置国内 Maven 镜像。
也可以使用命令行启动:
cd canteen-review-server # 这里换成你实际的后端目录 mvn spring-boot:run启动成功后,控制台会出现 Spring Boot 的启动日志,默认端口通常是 8080。
验证方式:浏览器访问http://localhost:8080/api/window/list。如果返回 JSON 数据,说明后端接口已正常启动。
6.4 启动前端
进入前端目录,安装依赖:
cd canteen-review-web # 这里换成你实际的前端目录 npm install npm run dev如果前端使用的是 Vite,控制台会显示Local: http://localhost:5173/。如果前端使用的是 Vue CLI,则启动命令一般是npm run serve,端口通常是 8080。为了避免和后端端口冲突,一般建议前后端使用不同端口,然后通过代理转发接口。
Vite 项目中可以在vite.config.js里配置:
// 文件路径:vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })配置完成后,前端页面里请求/api/window/list时,会被 Vite 自动转发到后端 8080 端口,从而避免跨域问题。
6.5 核心业务全流程验证
项目跑通后,建议你按下面这条路径演示一遍,这也是答辩时最推荐的演示路径:
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 注册学生账号并登录 | 成功跳转到首页 |
| 2 | 进入一个食堂窗口详情页 | 能看到窗口信息和已有点评 |
| 3 | 提交一条点评,选择匿名 | 提示“提交成功,等待审核” |
| 4 | 退出学生账号,登录管理员账号 | 后台点评管理出现待审核记录 |
| 5 | 管理员审核通过这条点评 | 前台窗口页能看到这条内容,但用户名显示为“匿名用户” |
| 6 | 回看窗口评分 | 窗口平均分已按最新点评重新计算 |
这条路径覆盖了普通用户的点评流程和管理员的审核流程,也顺带验证了匿名机制和统计更新,是整场演示中最能体现系统完整度的部分。
7. 常见问题与排查思路
拿到源码跑不通,大概率不是代码本身问题,而是环境或配置差异。下面是我从大量同类项目反馈中总结的高频问题和排查建议。
| 问题现象 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 后端启动失败,报数据库连接异常 | MySQL 没启动、账号密码错误、连接串端口不对 | 确认 MySQL 服务和连接串配置 | 用 Navicat 测试连接,修正 application.yml 中的账号密码 |
| 中文乱码 | 数据库字符集不是 utf8mb4,或连接串缺少 characterEncoding | 查看表字符集,查询输出是否乱码 | 建库时指定 utf8mb4,连接串改成 utf8mb4 并重启 |
| 前端口请求 404 或 500 | 后端接口路径与前端请求路径不一致 | 打开浏览器 F12 看 Network 面板 | 逐字核对请求 URL 和 Controller 的 @RequestMapping |
| 浏览器报跨域错误 | 前端端口和后端端口不一致,又没配代理或 CORS | 查看 Network 响应头是否有 Access-Control-Allow-Origin | 在 vite / vue.config 配置代理,或后端写全局 CorsConfig |
| npm install 报错 | Node 版本与项目依赖不匹配 | 执行node -v查看版本 | 换 Node 14/16/18 等兼容版本,删除 node_modules 后重装 |
| 分页查询不生效 | MyBatis-Plus 分页插件未配置 | 查看是否有 MybatisPlusInterceptor Bean | 在 config 包中添加分页拦截器 |
| 头像无法显示 | 图片路径是本机绝对路径或没有上传模块 | 观察图片 URL | 用本地静态资源映射或图床地址 |
| 管理员登录不了 | 初始化数据没导入,或密码加密规则不一致 | 查 sys_user 表是否有账号 | 检查项目使用了 BCrypt 还是 MD5,按对应用户插入账号 |
用一个比较典型的例子展开讲:MyBatis-Plus 分页拦截器缺失。
如果你调用 selectPage 后发现返回的总条数一直是 0,或者压根不进入分页 SQL,多半是在配置类里没有注册分页插件。可以这样补上:
// 文件路径:com.example.canteen.config.MybatisPlusConfig.java @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这个配置极其常见,但也是新手最喜欢漏掉的地方。类似的还有逻辑删除插件、乐观锁插件,都属于“少了不会编译报错,但运行行为不对”的隐蔽问题。
8. 如何把这个项目变成自己的毕业设计
很多人下载源码后会陷入一种矛盾:不用源码,自己从零写来不及;直接用源码,答辩时又心虚。
破解方法很简单,不是大幅重构,而是做“外科手术式”改进。挑一两个点,把它改得比原项目更合理,然后能清楚讲出为什么这样改。
8.1 把“一成点评”改成“可匿名且可重评”
原版系统如果只是简单判断“一个用户对应一个窗口只允许一条点评”,那直接实现唯一索引就完了。但你仔细想想,如果管理员把某个人的点评驳回了,他还有没有机会重新点评?如果用户当时评错了,想改怎么办?
更好的方案是设计成类似“最近一条有效点评”的逻辑:
- 学生提交点评时,先查询自己对该窗口最近一次有效点评。
- 如果最近点评处于待审核或已通过状态,前端提示不能重复评分。
- 如果最近点评被驳回,允许重新提交。
- 已通过的点评可以允许用户进入自己“我的点评”页面进行修改或删除。
这样既避免了刷屏,也保留了用户纠错空间。代码上的改动主要是去掉数据库里强制的(user_id, window_id)唯一索引,改用 Service 层查询判断,原先表设计里的 IN 条件已经没有重复,所以已经兼容这一点;你可以在此基础上把「我的点评」编辑功能补出来。
8.2 增加统计可视化页面
只有列表和审核的管理后台还是太单薄。如果想让答辩内容更充实,可以给管理员增加一个数据看板,用 ECharts 展示:
- 各个食堂窗口的平均分排名
- 评分分布图,比如 5 分、4 分、3 分分别占多少
- 一周内点评提交与审核通过数量的趋势
- 每个食堂窗口收到点评的数量排行
前端使用 ECharts 的方式也很成熟,核心是后端提供一个统计接口,返回 GroupBy 聚合