简介:一套面向毕业设计场景的垃圾分类识别小程序项目源码,基于SpringBoot框架构建,后端使用Java语言实现,并附带数据库脚本与部署说明,整体覆盖从后台服务到小程序前端展示的完整闭环流程。项目定位清晰,适合计算机、通信、人工智能、自动化等相关专业的学生作为课程设计、大作业或毕业设计参考,也便于初步接触小程序后端整合的开发者学习。压缩包内共208个文件,大小约44.13MB,以163个Java核心源码、28个XML配置与映射文件为主,另包含SQL数据库脚本、Properties/YAML配置、JAR依赖包及Markdown说明文档,目录结构完整,各文件用途明确。目前已有97人浏览学习,该工程为作者个人毕设,代码经过调试测试,答辩评审分达98分,可确保运行;附带数据库初始化脚本与部署说明,可减少环境配置与联调阶段的摸索时间。在垃圾分类识别流程、接口封装及页面联调方面都有清晰的实现思路,具备较好的学习借鉴与二次开发价值,基础较强的使用者可调整功能模块以适配不同场景。
1. SpringBoot垃圾分类识别小程序:从选题到部署的一条完整链路
如果你正在为即将到来的毕业设计找题目,又希望这套系统“看得见、摸得着、能当场演示”,SpringBoot垃圾分类识别小程序是一个很少出错的选项:用户在小程序里拍一张垃圾照片,后端用SpringBoot接住图片,跑识别逻辑,返回可回收、有害、厨余还是其他垃圾,同时把记录写进MySQL。技术栈覆盖微信小程序、后端框架、数据库和部署四件事,难度刚好卡在“能讲清楚”和“做得完”之间。这篇稿子把从建库到答辩的路径拆开讲:怎么设计表、怎么写识别接口、小程序端怎么传图、部署时哪里容易翻车、答辩演示要准备哪些细节。适合还没定题的学生,也适合想把手头半成品补成完整作品的人。
2. 系统架构与数据库设计:图片上传到分类回显的数据链路
拿到这套题目,我的习惯是先把数据流画清楚再动代码。完整链路是:小程序端通过wx.chooseMedia取得本地图片,再用wx.uploadFile把图片二进制提交到 SpringBoot 后端的/api/garbage/recognize;后端先把图片保存到本地磁盘或对象存储,调用识别服务得到分类结果和置信度,再把这条识别记录写入数据库,最后把结果返回给小程序渲染。用户看到分类名称、置信度和图片缩略图,一条闭环就此完成。
2.1 三层架构:小程序、SpringBoot、MySQL 各司其职
三层各自的职责要划清:小程序层只负责获取图片、请求接口、渲染结果,不碰任何业务计算;SpringBoot层负责接收上传、校验文件、执行识别、读写数据库,是唯一允许触碰数据源的层;MySQL层只做持久化,用户信息、垃圾类别字典、识别记录、纠错反馈四类数据都落在里面。为什么强调这一点?因为很多第一次做全栈的同学会把识别逻辑写在小程序里、把数据也塞进小程序的storage,后端只做一个空壳。开发是快了,但答辩时评委一句“你后端做了什么”就答不上来。把链路放在后端,既符合真实生产环境,答辩也更容易讲出层次。
从开发顺序上看,我建议先定接口再写页面。后端把/recognize和/history两个接口的定义固定下来,返回字段名也先锁死,小程序端照着字段写渲染逻辑。两边并行开发时,前后端只要守住这份 JSON 结构约定,就不会出现联调时互相等对方的尴尬。
2.2 四张核心表:字段设计与选择理由
在垃圾分类这个小业务里,表的数量不需要多,四张就够。
| 表名 | 业务作用 | 核心字段 | 备注 |
|---|---|---|---|
| t_user | 保存微信用户 | openid、nickname、avatar_url、created_at | openid 唯一,小程序 wx.login 后获取 |
| t_garbage_category | 垃圾类别字典 | code、name、description、sort_order | 预置可回收、有害、厨余、其他四类 |
| t_recognize_record | 每次识别流水 | user_id、image_url、category_code、confidence、is_correct、created_at | 按 (user_id, created_at) 建索引 |
| t_feedback | 用户纠错反馈 | record_id、user_id、correct_category_code、remark、created_at | 沉淀错误样本,后续可反哺识别 |
字段设计上有一个关键决定:类别字段用code字符串而不是自增 id。原因是垃圾分类文案在不同城市并不统一——上海叫干垃圾湿垃圾,北京叫厨余垃圾其他垃圾。如果业务表里直接存中文名,将来想换城市文案就要改一堆历史数据。存code后,前端展示时做一次映射就行,数据库不用动。
confidence字段用DECIMAL(5,4),范围足够存 0 到 1 之间的置信度;is_correct给纠错闭环预留,用户如果点了“识别错了”,这里记为 0,配合 t_feedback 表记录正确类别。还有一点要提醒:记录表里别设计一个 JSON 字段把整张图片的 base64 塞进去。image_url存文件路径或外部 URL,图片二进制落在磁盘或对象存储,否则数据库几个 G 很容易膨胀,备份迁移都难受。
2.3 建表脚本:直接能跑的 DDL 与参数含义
CREATE DATABASE IF NOT EXISTS garbage_sort DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE garbage_sort; CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', openid VARCHAR(64) NOT NULL COMMENT '微信openid', nickname VARCHAR(64) DEFAULT '' COMMENT '昵称', avatar_url VARCHAR(255) DEFAULT '' COMMENT '头像地址', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', UNIQUE KEY uk_openid (openid) ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE t_garbage_category ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', code VARCHAR(32) NOT NULL COMMENT '类别编码:recyclable/harmful/kitchen/residual', name VARCHAR(32) NOT NULL COMMENT '类别名称', description VARCHAR(255) DEFAULT '' COMMENT '类别描述', sort_order INT DEFAULT 0 COMMENT '展示顺序', UNIQUE KEY uk_code (code) ) ENGINE=InnoDB COMMENT='垃圾类别字典'; CREATE TABLE t_recognize_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', user_id BIGINT NOT NULL COMMENT '用户ID', image_url VARCHAR(255) NOT NULL COMMENT '识别图片URL', category_code VARCHAR(32) NOT NULL COMMENT '识别出的类别编码', confidence DECIMAL(5,4) DEFAULT 0 COMMENT '置信度', is_correct TINYINT DEFAULT NULL COMMENT '用户是否确认正确:0否 1是', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '识别时间', KEY idx_user_created (user_id, created_at) ) ENGINE=InnoDB COMMENT='识别记录表'; CREATE TABLE t_feedback ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', record_id BIGINT NOT NULL COMMENT '关联识别记录ID', user_id BIGINT NOT NULL COMMENT '用户ID', correct_category_code VARCHAR(32) NOT NULL COMMENT '用户认为的正确类别', remark VARCHAR(255) DEFAULT '' COMMENT '备注', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '反馈时间', KEY idx_record (record_id) ) ENGINE=InnoDB COMMENT='纠错反馈表';脚本里的排序规则用utf8mb4_general_ci就够用。字符集之所以必须选 utf8mb4 而不是 utf8,是因为微信用户昵称和垃圾图片备注里会出现 emoji,utf8mb4 是 MySQL 5.7 之后能完整存储四字节字符的选择,无脑用它不会错。
索引只需要两条:openid 的唯一索引防止同一用户重复插入;识别记录表的(user_id, created_at)联合索引支撑历史记录页按用户倒序查询。其他字段先不加索引,等数据量真的上来了再加,不为不存在的查询瓶颈买单。
3. SpringBoot后端落地:识别接口、数据源配置与核心代码
后端这部分是把标题里的 springboot框架 真正立起来的地方。我会按工程骨架、配置文件、Controller 与 Service 三层来展开,最后说明识别策略怎么选。全程用 JdbcTemplate,不引入 MyBatis-Plus,因为这个体量的业务用简单 JDBC 就够清楚,代码量也更容易让答辩评委看懂。
3.1 工程骨架:用 2.7.x 打底,避开 SpringBoot 3.0 的版本坑
搭这类项目,SpringBoot 版本我会固定到 2.7.x,比如 2.7.18。原因很现实:3.x 起把javax包改名成jakarta,JDK 最低要求 17,而不少机器上装的还是 JDK 8。不是说 3.x 不能用,而是 2.7.x 的教程、插件、踩坑问答最多,遇到问题搜得到答案。springboot 版本太高确实会让本地环境直接翻车,这一节先把这个雷避开。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>依赖里我没放 Lombok。有些同学喜欢用它省去 getter/setter,但毕设项目里我建议少一个注解处理就少一层不确定性,IDE 的 Lombok 插件没装好时会看到一堆“找不到方法”的编译错误。老老实实手写 DTO 的 getter/setter,代码量多不了几行,排除故障时更直接。
3.2 数据源与上传配置:application.yml 的三个关键项
springboot 配置集中在application.yml,这里有三项必须写对。
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/garbage_sort?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 10MB max-request-size: 20MB garbage: recognize: provider: localURL 里的三个参数各有用处:useUnicode和characterEncoding=utf8解决中文写入乱码,serverTimezone=Asia/Shanghai解决 MySQL 8.x 的时区报错。driver-class-name必须写com.mysql.cj.jdbc.Driver,这是 mysql-connector-j 8.x 的驱动类名,写旧的com.mysql.jdbc.Driver会直接启动失败,第 5 章会把这个坑再拎出来说。
上传大小限制是 creamboard 里最容易忽略的一项。SpringBoot 默认单文件最大 1MB,手机上随便一张照片就超了。max-file-size控制单个文件,max-request-size控制整个请求体,按 10MB/20MB 设置比较宽松。
3.3 识别主链路:Controller 与 Service 的代码实现
接口分层按 Controller → Service 来做,Controller 只做参数校验,业务逻辑全部进 Service。
package com.example.garbage.controller; import com.example.garbage.common.Result; import com.example.garbage.service.GarbageService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/garbage") public class GarbageController { @Autowired private GarbageService garbageService; @PostMapping("/recognize") public Result recognize(@RequestParam("file") MultipartFile file, @RequestParam(value = "userId", required = false) Long userId) { if (file == null || file.isEmpty()) { return Result.error(400, "图片不能为空"); } if (file.getSize() > 10 * 1024 * 1024) { return Result.error(400, "图片超过10MB限制"); } if (file.getContentType() == null || !file.getContentType().startsWith("image/")) { return Result.error(400, "只支持图片文件"); } return Result.ok(garbageService.recognize(file, userId)); } @GetMapping("/history") public Result history(@RequestParam Long userId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { return Result.ok(garbageService.history(userId, page, size)); } }Controller 里做了三层校验:文件非空、大小不超限、MIME 类型必须 image/ 开头。这些校验看起来基础,但能挡住一大部分异常请求,也让答辩现场不会因为传了个 txt 文件就报 500。
package com.example.garbage.service; import com.example.garbage.common.Result; import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.util.UUID; @Service public class GarbageService { private final JdbcTemplate jdbcTemplate; @Value("${garbage.recognize.provider:local}") private String provider; public GarbageService(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } public RecognizeResult recognize(MultipartFile file, Long userId) { String imageUrl = saveImage(file); CategoryResult categoryResult; if ("cloud".equals(provider)) { categoryResult = cloudRecognize(file); } else { categoryResult = localRuleRecognize(file.getOriginalFilename()); } String insertSql = "INSERT INTO t_recognize_record (user_id, image_url, category_code, confidence) VALUES (?, ?, ?, ?)"; jdbcTemplate.update(insertSql, userId, imageUrl, categoryResult.getCode(), categoryResult.getConfidence()); return RecognizeResult.builder() .imageUrl(imageUrl) .categoryCode(categoryResult.getCode()) .categoryName(CategoryCodeMapper.getName(categoryResult.getCode())) .confidence(categoryResult.getConfidence()) .build(); } private String saveImage(MultipartFile file) { String dir = System.getProperty("user.dir") + "/uploads/"; File folder = new File(dir); if (!folder.exists()) { folder.mkdirs(); } String original = file.getOriginalFilename(); String ext = original == null ? ".jpg" : original.substring(original.lastIndexOf(".")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; File dest = new File(folder, filename); try { file.transferTo(dest); } catch (Exception e) { throw new RuntimeException("图片保存失败", e); } return "/uploads/" + filename; } private CategoryResult localRuleRecognize(String filename) { String name = filename == null ? "" : filename.toLowerCase(); if (name.contains("电池") || name.contains("过期药")) { return CategoryResult.of("harmful", 0.90); } if (name.contains("果皮") || name.contains("剩饭")) { return CategoryResult.of("kitchen", 0.90); } if (name.contains("纸箱") || name.contains("塑料瓶")) { return CategoryResult.of("recyclable", 0.90); } return CategoryResult.of("residual", 0.60); } private CategoryResult cloudRecognize(MultipartFile file) { // 接入云厂商图像识别API时,在这里完成base64编码与HTTP调用 return localRuleRecognize(file.getOriginalFilename()); } public PageResult history(Long userId, Integer page, Integer size) { String countSql = "SELECT COUNT(*) FROM t_recognize_record WHERE user_id = ?"; Integer total = jdbcTemplate.queryForObject(countSql, Integer.class, userId); String listSql = "SELECT id, image_url, category_code, confidence, created_at " + "FROM t_recognize_record WHERE user_id = ? " + "ORDER BY created_at DESC LIMIT ? OFFSET ?"; List<RecordItem> list = jdbcTemplate.query(listSql, (rs, rowNum) -> RecordItem.builder() .id(rs.getLong("id")) .imageUrl(rs.getString("image_url")) .categoryCode(rs.getString("category_code")) .confidence(rs.getBigDecimal("confidence")) .createdAt(rs.getString("created_at")) .build(), userId, size, (page - 1) * size); return PageResult.of(list, total, page, size); } }saveImage用 UUID 做文件名,避免用户上传同名文件互相覆盖,后缀从原文件名里截取。目录用user.dir + "/uploads/"是相对路径,换服务器不用改配置,缺点是所有图片堆在一个目录,本次项目量级完全够用。
provider参数控制识别策略,默认 local。localRuleRecognize 按文件名关键词匹配,属于零成本兜底玩法;cloudRecognize 的注释位置留给云 API,做毕业设计时可以注册云厂商的垃圾分类图像识别服务,把图片 base64 编码后 POST 过去,拿到候选类别和置信度再映射到自己的 code。用配置项切两种模式的好处是:平时演示用 local 不依赖外网,答辩要秀真实识别效果时改一个配置重启即可。
history方法做了分页。LIMIT ? OFFSET ?的第二个参数是 offset,计算方式是(page - 1) * size,前端传 1 时要能正确翻到第一页数据。总数查询和列表查询拆开写,代码更直白,也方便前端在列表底部显示“共 xx 条”的总数统计。
4. 小程序端落地:拍照、上传、结果渲染与历史记录
小程序端我按原生小程序写,不用 uniapp。原因很直接:这个题目只需要微信一个端,原生项目的启动速度最快、调试路径最短,遇到问题直接对得上微信官方文档。如果将来想发到支付宝、抖音小程序,再抽成 uniapp 也不迟。页面就两页:index 识别主页和 history 历史记录页。
4.1 项目结构:从 app.json 到页面文件组织
{ "pages": [ "pages/index/index", "pages/history/history" ], "window": { "navigationBarTitleText": "垃圾分类识别", "navigationBarBackgroundColor": "#07C160", "navigationBarTextStyle": "white" }, "style": "v2", "sitemapLocation": "sitemap.json" }pages数组里第一个就是启动页,把 index 放最前面。navigationBarTitleText设置顶部导航栏文案,垃圾分类识别这个名称直接透出业务主题。小程序端代码总量不大,没必要引入分包,两页平铺即可。
4.2 识别页:拍照选图、上传、回显的核心代码
先看页面结构:
<view class="page"> <view class="preview"> <image wx:if="{{imageUrl}}" src="{{imageUrl}}" mode="aspectFit"/> <text wx:else>点击下方按钮拍照或从相册选择垃圾图片</text> </view> <button class="action-btn" bindtap="chooseImage" loading="{{loading}}">拍照识别</button> <view wx:if="{{result}}" class="result-card"> <text class="category-name">{{result.categoryName}}</text> <text class="confidence">置信度:{{result.confidence}}</text> <image class="thumb" src="{{result.imageUrl}}" mode="aspectFill"/> </view> </view>页面逻辑:
Page({ data: { imageUrl: '', result: null, loading: false }, chooseImage() { wx.chooseMedia({ count: 1, mediaType: ['image'], sourceType: ['camera', 'album'], success: (res) => { const tempFilePath = res.tempFiles[0].tempFilePath; this.setData({ imageUrl: tempFilePath, result: null }); this.uploadImage(tempFilePath); } }); }, uploadImage(filePath) { this.setData({ loading: true }); wx.showLoading({ title: '识别中' }); wx.uploadFile({ url: 'http://localhost:8080/api/garbage/recognize', filePath: filePath, name: 'file', formData: { userId: 1 }, success: (res) => { const data = JSON.parse(res.data); if (data.code === 200) { this.setData({ result: data.data }); } else { wx.showToast({ title: data.msg, icon: 'none' }); } }, fail: () => { wx.showToast({ title: '网络请求失败', icon: 'none' }); }, complete: () => { wx.hideLoading(); this.setData({ loading: false }); } }); } });wx.chooseMedia是官方推荐的媒体选择 API,替代了早期版本里的wx.chooseImage。count限制为 1,避免一次选多张;sourceType把 camera 放前面,答辩演示时点一下直接进入拍照,效率最高。wx.uploadFile的name字段必须跟后端@RequestParam("file")保持一致,formData里的 userId 在真实项目里应该由登录态决定,但毕设阶段我习惯先固定一个值顶住,把精力留给识别链路。
回显时的imageUrl直接用了后端返回的/uploads/xxx.jpg。如果后端没有部署静态资源映射,这个地址会 404,需要在后端加一个ResourceHandler配置把/uploads/**映射到磁盘目录。这一步比较隐蔽,排查时容易误认为是接口写错了。
4.3 历史记录页:拉取列表与触底加载更多
Page({ data: { records: [], page: 1, size: 10, hasMore: true, loading: false }, onLoad() { this.loadHistory(); }, loadHistory() { if (this.data.loading) return; this.setData({ loading: true }); wx.request({ url: 'http://localhost:8080/api/garbage/history', data: { userId: 1, page: this.data.page, size: this.data.size }, success: (res) => { const list = res.data.data.list; this.setData({ records: this.data.records.concat(list), page: this.data.page + 1, hasMore: list.length >= this.data.size }); }, complete: () => { this.setData({ loading: false }); } }); }, onReachBottom() { if (!this.data.hasMore) return; this.loadHistory(); } });微信小程序页面列表加载更多的标准做法就是 onReachBottom 触底回调。这里有个细节:page在成功拿到数据之后才自增,避免失败时页码已经前进、重新拉取会跳过一页。hasMore的判定依据是当前页返回数量是否等于请求的 size,等于还可能有下一页,小于则说明到底了。
历史记录列表的数据结构要跟后端PageResult对齐,后端返回data.list、data.total,前端只管把 list 拼进数组。后端分页逻辑变化时前端不用跟着改。
5. 部署与避坑:本地联调、服务器上线与 5 个高频问题
部署环节直接决定答辩现场能不能把项目跑起来。这里既有“不校验域名”这种本地联调开关,也有服务器上nohup跑 jar 的常规操作,最后落在一组具体的踩坑记录上。
5.1 本地三端联调:不校验域名与数据源核对
本地把三端跑起来,依次做三件事:MySQL 执行建表脚本;SpringBoot 启动并确认看到 Started 日志;小程序开发工具勾选“详情 - 本地设置 - 不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”。前两件事不出意外的话很顺利,第三件事是本地联调的关键开关。
微信小程序默认只允许请求 HTTPS 且域名已备案的接口,localhost显然不在白名单里。开发工具里这个开关就是给本地联调用的,不勾上的话,一刷新就报“不在以下合法域名列表中”。真机预览时,还要把小程序请求地址从http://localhost:8080换成电脑的局域网 IP,手机和电脑连同一个 Wi-Fi 才能访问。
另外我习惯在后端加一个 CORS 配置。小程序原生请求不受浏览器同源策略限制,但答辩时老师如果直接用浏览器打开后端地址试探接口,跨域会挡住。加这一段,开发期能少很多“页面白屏但接口明明有数据”的玄学问题。
package com.example.garbage.config; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST") .allowedHeaders("*"); } }CORS 配置里allowedOriginPatterns("*")表示允许所有来源,正式上线时应该收紧成你自己的域名。毕业设计阶段放开即可,省去反复调试跨域的麻烦。
5.2 服务器部署:打包、上传、启动一条命令
云服务器部署的本质就是把 jar 包和应用依赖的环境放到一台长期运行的机器上。先确认服务器 JDK 是 1.8,再执行:
mvn clean package -DskipTests scp target/garbage-sort-0.0.1-SNAPSHOT.jar root@your-server:/opt/app/ ssh root@your-server cd /opt/app nohup java -jar garbage-sort-0.0.1-SNAPSHOT.jar > app.log 2>&1 &-DskipTests跳过测试,毕设项目不用在每个环境的测试上耗时间。nohup让进程脱离当前 SSH 会话继续运行,后面加&表示后台执行,> app.log 2>&1把标准输出和错误输出都重定向到日志文件。
这一步不做的话,你关掉终端,服务就跟着断开,这在答辩现场非常致命。启动之后用curl http://127.0.0.1:8080/api/garbage/history?userId=1&page=1&size=10验证接口,能返回 JSON 就说明服务活了。起不来就看app.log,日志里定位错误比盲猜有效得多。
5.3 五个高频踩坑记录:现象、原因、解决
**坑一:小程序请求报“不在以下合法域名列表中”**现象:开发工具里点识别按钮,请求直接 fail,console 提示域名不合法。原因:微信小程序对 request/uploadFile 的域名有强制校验,要求 HTTPS 且已备案,本地开发环境不满足。解决:开发工具详情页勾选“不校验合法域名”即可;真机预览也要在手机端打开调试模式,否则同样会被拦。
坑二:后端启动报 SQLException 驱动类错误现象:SpringBoot 启动到数据源初始化时报ClassNotFoundException: com.mysql.jdbc.Driver。原因:mysql-connector-j 8.x 驱动类名变成了com.mysql.cj.jdbc.Driver,旧类名已经被移除。解决:把 application.yml 里的 driver-class-name 改成com.mysql.cj.jdbc.Driver,同时确认 pom 里的是 8.0.x 版本而不是 5.x。
坑三:上传照片报 size exceeds the configured maximum现象:小程序选图后上传,后端返回 500,日志提示文件大小超过限制。原因:SpringBoot 默认max-file-size只有 1MB,手机照片动辄 3MB 以上。解决:在 application.yml 的spring.servlet.multipart节点下把max-file-size和max-request-size调大。
坑四:SpringBoot 版本太高跟 JDK8 环境不兼容现象:项目导入后编译直接报UnsupportedClassVersionError,或者大量 jakarta 报错。原因:SpringBoot 3.x 强制要求 JDK17,毕设电脑和服务器上装的还是 JDK8。解决:把 parent 版本回退到 2.7.x,Maven 重新刷新依赖。这个坑踩过的人最多,也是最没必要的翻车方式。
坑五:真机预览连不上后端现象:真机上打开小程序,按钮点了没反应,请求一直转圈。原因:手机和电脑不在同一局域网,或者后端的server.address绑定了 localhost。解决:后端不配置server.address或设为0.0.0.0,小程序请求地址改成电脑的局域网 IP,Windows 防火墙放行 8080 端口,最后用手机浏览器打开http://电脑IP:8080/api/garbage/history验证连通性。
6. 进阶验证与答辩技巧:识别准确率、分页加载与一场稳的演示
识别准确率是这个项目能不能打动评委的核心,但毕设阶段没必要训练自己的模型。更务实的做法是:后端返回三个候选类别而不是一个,前端把候选列出来让用户“确认”或“纠正”,确认结果写回is_correct,纠正结果写进 t_feedback。这样设计之后,识别过程从“黑匣子给一个答案”变成“人与系统共同校准”,答辩时能清晰讲出数据回流闭环。
置信度阈值也要用起来。当接口返回的 confidence 低于 0.6 时,前端不要直接展示结论,而是弹出一个分类选项让用户手动选择。这个交互细节在演示时很容易被注意到——评委通常会追问“识别错了怎么办”,你已经有答案了。
答辩演示有三个细节我吃了亏之后才总结出来:第一,提前准备 3 到 5 张特征明显的垃圾图片(电池、香蕉皮、塑料瓶、纸箱),在正式答辩前用这套图完整跑一遍,确认结果稳定;第二,演示前清空 t_recognize_record 表,让历史记录从零开始跑,现场每识别一次列表就增长一条,数据变化直观可见;第三,手机和电脑连同一个 Wi-Fi,后端用局域网启动,别到了现场才发现真机连不上。
我自己最初做这套全栈题目时,翻车最狠的一次是纠结 SpringBoot 到底用 2.x 还是 3.x,来回改依赖折腾掉一个晚上。后来我给自己定了个习惯:毕业设计这类短期项目先用 2.7.x 跑通,把精力放在数据流和演示效果上,再研究新版本的特性。把版本玄学排除在项目主链路之外,你会发现这个垃圾分类识别项目真正值钱的地方不在某个框架版本,而在于你把数据的来龙去脉和识别闭环讲圆了。希望帮到你。
本文还有配套的精品资源,点击获取