简介:一份完整的垃圾分类小程序毕业设计项目包,基于微信小程序与SpringBoot、MySQL开发,前端小程序使用uni-app框架,后台管理采用Vue与Layui实现,面向计算机、软件类毕业设计学生,可作课程设计与论文参考。压缩包共796个文件,大小35.12MB,以Java源码、Vue页面、WXML/WXSS小程序页面、JSON配置为主,另含SQL数据库脚本、Word格式开题报告和毕业论文文档,同时附带视频演示。已有116人学习下载。项目覆盖管理员与用户两类角色,功能包括垃圾信息管理、环保视频与新闻展示、知识问答试卷与测试、论坛信息管理、轮播图维护等;模块目录清晰,便于按业务拆分理解。除基础业务功能外,也针对数据信息安全性给出实用方案,既能帮助掌握全栈开发流程与数据库设计,也可直接支撑毕业设计文档撰写与答辩展示。
1. 垃圾分类小程序毕业设计:源码、数据库、论文、视频演示一份不能少
把“垃圾分类小程序”当毕业设计题目的人,多半是看中了它的全栈属性:微信小程序做前端,SpringBoot 提供后端接口,MySQL 存业务数据,交付物还要求带源码、数据库、开题报告、毕业论文和视频演示。很多同学前期只闷头写代码,等提交材料才发现,论文和演示视频才是拉开分差的地方。下面按我实际带过的一类方案,把从建表、接口、页面到文档组织的完整路径讲透,新手能照着跑通,熟手可以直接翻到第5章看踩坑、第6章看答辩准备。适合正在做这个题目、或者需要快速交付一套可演示毕设方案的读者。
2. 先拆业务再建表:垃圾分类模块划分与 MySQL 表结构落地
2.1 垃圾分类的业务闭环:四个模块,不是只做搜索
垃圾分类小程序绝大部分功能集中在“查垃圾”上,很多网上开源版本也确实只有一个搜索框。但作为毕业设计,只做一个搜索框撑不起开题报告里的“研究内容”,答辩时也容易被追问“系统功能是否完整”。我一般会把业务拆成四个模块。
第一个是垃圾查询:输入“废电池”“剩饭”这类词,返回垃圾所属分类和投放建议,这是核心功能,演示时第一个演示它。第二个是分类百科:按可回收物、有害垃圾、厨余垃圾、其他垃圾四个 tab 浏览,每条垃圾配图和说明,这一块画面最好看,适合截图放进开题报告。第三个是积分激励:用户完成查询、签到、参与小游戏可以获得积分,积分用于兑换虚拟奖品或展示个人环保档案,这是论文“创新点”的主要来源。第四个是后台管理:管理员维护垃圾分类条目和类别数据,做增删改查。管理工作虽然不起眼,但论文里“系统管理模块”几乎是必写章节,不做这个模块,详细设计那一章会缺一大块。
四类垃圾的分类标准属于业务常识:可回收物包括纸张、塑料、金属、玻璃类;有害垃圾包括废电池、废灯管、过期药品;厨余垃圾是剩饭剩菜、瓜皮果核;其他垃圾是烟头、灰土和受污染的纸张。设计表结构时,分类不要硬编码在小程序前端,而是放进数据库表里由后台维护,这样后续分类标准有调整也不用改代码,论文中“系统可维护性”也有素材可写。
2.2 核心表结构:四张表承载全部业务
我习惯先用四张表把项目撑起来。不要一上来就设计七八张表,毕设数据量不大,表多了联查分页反而难调试。第一张 category 垃圾类别表,字段为 id、name、description、icon、sort_order。id 是自增主键,name 存“可回收物”这种名称,description 放类别说明,icon 存图标地址,sort_order 控制小程序端 tab 的显示顺序。
第二张 garbage 垃圾条目表,字段为 id、name、category_id、description、disposal_advice、image_url、create_time。name 存具体垃圾名称,category_id 关联 category 表,disposal_advice 存投放建议,例如“请投入蓝色可回收物桶”,image_url 配示例图。这张表是查询接口的主表,name 字段要加索引,因为按关键词搜索是最高频的操作。
第三张 user 用户表,字段为 id、openid、nickname、avatar_url、points、create_time。openid 是微信用户的唯一标识,必须加唯一索引,避免同一用户重复注册。第四张 score_record 积分记录表,字段为 id、user_id、change_points、reason、create_time。change_points 用正负号记录积分变动,加积分存正数,兑换扣减存负数,统计用户总积分时用 SUM 函数一次算出。
一个常被问起的点是:category_id 要不要真的建物理外键。我不建,只用普通索引并在应用层保证数据一致。原因是 MyBatis-Plus 的 BaseMapper 做删除操作时,如果存在物理外键约束,不小心删了仍有条目引用的分类就会报“外键约束失败”,调试成本高,答辩时解释起来也绕。逻辑外键通过字段命名和联查 SQL 体现,已经足够满足毕设要求。
2.3 建表 SQL 和初始化数据:一个脚本跑通数据库
为了减少在环境问题上浪费时间,我把建库、建表、初始化数据全部放在一个 .sql 文件里,导入后直接能用。下面这份脚本在 MySQL 5.7 和 8.0 上都能直接跑,是我平时用得比较顺的一版。
CREATE DATABASE IF NOT EXISTS garbage_classification DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE garbage_classification; CREATE TABLE category ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '类别ID', name VARCHAR(20) NOT NULL COMMENT '类别名称', description VARCHAR(255) DEFAULT '' COMMENT '类别说明', icon VARCHAR(255) DEFAULT '' COMMENT '类别图标', sort_order TINYINT DEFAULT 0 COMMENT '排序号' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='垃圾类别表'; CREATE TABLE garbage ( id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT COMMENT '垃圾ID', name VARCHAR(50) NOT NULL COMMENT '垃圾名称', category_id INT UNSIGNED NOT NULL COMMENT '所属类别ID', description VARCHAR(500) DEFAULT '' COMMENT '垃圾详情描述', disposal_advice VARCHAR(255) DEFAULT '' COMMENT '投放建议', image_url VARCHAR(255) DEFAULT '' COMMENT '示例图片', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', KEY idx_category_id (category_id), KEY idx_name (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='垃圾条目表'; INSERT INTO category (name, description, sort_order) VALUES ('可回收物', '适宜回收利用和资源化利用的生活废弃物', 1), ('有害垃圾', '对人体健康或自然环境造成直接或潜在危害的废弃物', 2), ('厨余垃圾', '易腐烂的、含有机质的生活废弃物', 3), ('其他垃圾', '除以上三类外的其他生活废弃物', 4); INSERT INTO garbage (name, category_id, description, disposal_advice) VALUES ('报纸', 1, '干净的纸张类', '压平后投入可回收物收集容器'), ('矿泉水瓶', 1, '塑料类容器', '清空内容物后压扁投放'), ('废电池', 2, '含汞等有害物质', '投入有害垃圾专用容器'), ('过期药品', 2, '化学药品', '连同包装一并投放'), ('剩饭剩菜', 3, '食品残渣', '沥干水分后投放'), ('果皮', 3, '瓜果皮核', '直接投入厨余垃圾容器'), ('烟头', 4, '污染过的固体废弃物', '熄灭后投入其他垃圾容器'), ('灰土', 4, '装修或清扫产生的尘土', '袋装后投入其他垃圾容器');脚本分四段:第一段建库并指定 utf8mb4 字符集;第二段建 category 和 garbage 两张核心表;第三段插入四条分类数据;第四段插入八条演示用垃圾条目。字段都加了 COMMENT,在 Navicat 里看起来一目了然,论文数据库设计章节可以直接截图用。garbage 表上建了两个普通索引:idx_category_id 加快按分类查询,idx_name 加快按名称搜索。演示数据量虽然用不到索引,但论文里写“为高频查询字段建立索引”是有分量的。
user 和 score_record 两张表的建表逻辑与 garbage 完全一致,把字段换成 2.2 节定义的字段即可,这里不重复占用篇幅。需要注意 user 表的 openid 字段要建唯一索引UNIQUE KEY uk_openid (openid),这是微信登录模块能正确归因到用户的关键。
2.4 一次连表查询示例:分类名和投放建议一起返回
小程序端的搜索接口,核心 SQL 语义就是一次连表查询。下面这条 SQL 也是论文“数据库访问”小节常用的示例。
SELECT g.id, g.name, g.disposal_advice, c.name AS category_name FROM garbage g LEFT JOIN category c ON g.category_id = c.id WHERE g.name LIKE '%电池%' ORDER BY g.id DESC LIMIT 0, 10;LIKE '%电池%' 是模糊查询,能匹配“废电池”“电池组”这类包含词。LIMIT 0, 10 表示第 1 页每页 10 条,翻页时把 0 换成 10、20 即可。LEFT JOIN 保证即使某条垃圾数据没挂分类,也能查出记录,只是 category_name 为 null。实际开发时这段逻辑由 MyBatis-Plus 的 QueryWrapper 生成,但 SQL 语义和这里是完全一致的。
3. SpringBoot 后端:从依赖配置到登录与查询接口
3.1 工程骨架与核心依赖:别把版本升得太高
为了最快把后端跑起来,我推荐 SpringBoot 2.7.x 搭配 MyBatis-Plus 3.5.x。原因之一是网上绝大多数毕设教程和踩坑答案都基于 SpringBoot 2,遇到问题好查;另一个原因是 SpringBoot 3 把 javax 换成了 jakarta,很多老代码直接编译报“程序包 javax.servlet 不存在”,为追新版本去改代码不划算。
<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>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>starter-web 提供 Spring MVC 和内置 Tomcat;mybatis-plus-boot-starter 负责数据访问和分页插件;mysql-connector-java 是 JDBC 驱动;Lombok 用注解减少实体类 getter/setter。如果你已经用了 SpringBoot 3.x,需要把 MyBatis-Plus 换成 mybatis-plus-spring-boot3-starter 并升到 3.5.9 以上,否则启动多半会报找不到 sqlSessionFactory。
接下来是 application.yml,数据源和 MyBatis-Plus 的关键配置都在这里。
server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/garbage_classification?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: trueurl 里的 characterEncoding=utf8 保证小程序端提交的中文参数不乱码;serverTimezone=Asia/Shanghai 是给新版 MySQL 驱动用的,不加会报时区错误。log-impl 打开 SQL 日志,排查“查不到数据”这类问题特别好用;答辩前把它注释掉,控制台会干净很多。map-underscore-to-camel-case 设为 true 之后,表字段 category_id 能自动映射到实体属性 categoryId,不用挨个写映射配置。
3.2 实体类与 Mapper:BaseMapper 实现零 SQL 单表操作
对应的 Java 实体类和方法接口代码是:
@Data @TableName("garbage") public class Garbage { @TableId(type = IdType.AUTO) private Integer id; private String name; private Integer categoryId; private String description; private String disposalAdvice; private String imageUrl; private LocalDateTime createTime; }public interface GarbageMapper extends BaseMapper<Garbage> { }@Data 来自 Lombok,自动生成 getter/setter。@TableName 指定实体对应表名,@TableId 标记主键并指定自增。categoryId 的驼峰命名会自动映射到表的 category_id。BaseMapper 提供了 selectById、insert、updateById、deleteById 等单表方法,查询接口只需要在这个基础上组合条件。
这里有一个我个人比较坚持的简化:不要在这一阶段引入一堆 Service 接口和 ServiceImpl 实现类。毕设代码量不大,直接把 Service 写成一个类,Controller 调 Service 就行。少一层接口就少一个需要解释的概念,答辩时也少一个“为什么这样分层”的追问机会。
3.3 垃圾分类查询接口:搜索、分页、按分类筛选组合在一起
Controller 里写一个 search 接口,同时处理关键词搜索和分类筛选。毕设最常用的写法如下。
@RestController @RequestMapping("/api/garbage") public class GarbageController { @Resource private GarbageMapper garbageMapper; @GetMapping("/search") public Result search(@RequestParam(required = false) String keyword, @RequestParam(required = false) Integer categoryId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { LambdaQueryWrapper<Garbage> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.like(Garbage::getName, keyword); } if (categoryId != null) { wrapper.eq(Garbage::getCategoryId, categoryId); } wrapper.orderByDesc(Garbage::getId); Page<Garbage> result = garbageMapper.selectPage( new Page<>(page, size), wrapper); return Result.ok(result); } }keyword 和 categoryId 都是可选参数:keyword 走 like 模糊匹配,categoryId 做精确分类筛选。page 从 1 开始,size 默认 10,selectPage 自动生成带 LIMIT 的分页 SQL。Result 是统一返回体,包含 code、message、data 三个字段,小程序端只需要判断 code 是否为 200。LambdaQueryWrapper 的 like 会自动给 keyword 加百分号,不需要手动拼接 %% 。
3.4 登录接口:wx.login 换 openid,别做手机号登录
小程序端 wx.login 拿到的 code 是一次性临时凭证,后端要拿它换 openid。这个过程不需要引入微信官方 SDK,JDK 自带的 HttpClient 就能完成,接口逻辑如下。
@PostMapping("/login") public Result login(@RequestBody Map<String, String> body) { String code = body.get("code"); String url = "https://api.weixin.qq.com/sns/jscode2session?" + "appid=" + appId + "&secret=" + appSecret + "&js_code=" + code + "&grant_type=authorization_code"; String resp = httpGet(url); JSONObject json = JSON.parseObject(resp); String openid = json.getString("openid"); User user = userMapper.selectOne( new LambdaQueryWrapper<User>().eq(User::getOpenid, openid)); if (user == null) { user = new User(); user.setOpenid(openid); userMapper.insert(user); } String token = UUID.randomUUID().toString().replace("-", ""); return Result.ok(token); }流程是标准的小程序登录三步走:wx.login 拿 code → 后端调 jscode2session 接口换 openid → 本地查表建用户并返回 token。这里用现成的 httpGet 方法,内部就是 JDK 的 HttpURLConnection 或 Apache HttpClient,代码量不大。appId 和 appSecret 要放在 application.yml 里,不要写死在 Java 代码中,论文里可以写“敏感配置外置”。
特别提醒一句:微信小程序登录获取手机号这个能力,要求小程序是企业主体并且完成微信认证,个人主体的毕设项目根本调不通。不要设计“手机号登录”流程,老老实实用 wx.login 换 openid 就好。很多同学在登录模块卡了一周,最后发现不是代码问题,而是资质问题。
4. 微信小程序前端:目录结构、请求封装与页面实现
4.1 小程序工程目录与全局配置:页面别贪多
小程序端的目录规划很影响开发效率。我常用的结构是三个页面对应三个 tab,刚起步不要规划太多页面。
miniprogram/ ├── app.js ├── app.json ├── app.wxss ├── pages/ │ ├── index/ 首页搜索 │ ├── category/ 分类百科 │ └── user/ 个人中心 └── utils/ └── request.js 请求封装app.json 的全局配置如下:
{ "pages": [ "pages/index/index", "pages/category/category", "pages/user/user" ], "window": { "navigationBarTitleText": "垃圾分类助手", "navigationBarBackgroundColor": "#07c160", "navigationBarTextStyle": "white" }, "tabBar": { "list": [ { "pagePath": "pages/index/index", "text": "查询" }, { "pagePath": "pages/category/category", "text": "分类" }, { "pagePath": "pages/user/user", "text": "我的" } ] } }三个页面分别对应用户查询、分类百科、个人中心。navigationBarBackgroundColor 用绿色,和环保主题契合。如果你后续要自定义顶部导航栏,需要在 window 里配置 "navigationStyle": "custom",然后自行适配状态栏高度;毕设没有特殊视觉需求就不要自定义导航栏,微信默认处理顶部安全区,少给自己加活。
4.2 请求封装:把 wx.request 包成 Promise
页面里如果每个请求都直接调 wx.request,代码会非常散。我习惯统一封装一个 request.js,所有接口走同一个入口。
const BASE_URL = 'http://localhost:8080/api'; function request(path, method = 'GET', data = {}) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + path, method, data, timeout: 8000, header: { 'Content-Type': 'application/json', 'token': wx.getStorageSync('token') || '' }, success: (res) => { if (res.statusCode === 200 && res.data.code === 200) { resolve(res.data.data); } else { wx.showToast({ title: res.data.message || '请求失败', icon: 'none' }); reject(res.data); } }, fail: (err) => reject(err) }); }); } module.exports = { request, BASE_URL };把 wx.request 包成 Promise 后,页面里用 async/await 调用,代码简洁很多。BASE_URL 指向本地后端,开发阶段很方便。注意一个经典坑:真机调试时 localhost 指向手机本身,必须改成电脑的局域网 IP,例如 http://192.168.1.100:8080/api,否则真机预览会一直请求失败。开发者工具调试时,还要在“详情-本地设置”勾选“不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书”,不然开发阶段就会提示域名不在白名单。
4.3 首页搜索与分页:防抖、加载更多与 onReachBottom
首页的核心交互是“输入关键词 -> 出结果”。如果不做防抖,每敲一个字就会发一次请求,既浪费后端资源,也会让页面出现明显的卡顿。我会做 200ms 防抖。
let searchTimer = null; Page({ data: { keyword: '', list: [], page: 1, hasMore: true }, onInput(e) { clearTimeout(searchTimer); const keyword = e.detail.value; searchTimer = setTimeout(() => { this.setData({ keyword, page: 1, list: [] }); this.loadList(); }, 200); }, async loadList() { if (!this.data.hasMore) return; const data = await request('/garbage/search', 'GET', { keyword: this.data.keyword, page: this.data.page, size: 10 }); const list = this.data.page === 1 ? data.records : this.data.list.concat(data.records); this.setData({ list, page: this.data.page + 1, hasMore: data.records.length === 10 }); }, onReachBottom() { this.loadList(); } });onInput 里先清掉上一次定时器,200ms 后才真正发请求。翻页时 page+1,后端按 size=10 返回,如果 records 长度不足 10,说明没有更多数据,hasMore 置为 false,避免滚动到底部时无限请求。这就是微信小程序页面列表加载更多的标准写法,论文里也可以作为关键技术点描述。setData 的合并逻辑注意一下:第一页直接覆盖,后续页追加,否则翻页时会丢数据。
4.4 分类百科页:Tab 切换与数据联动
分类百科页用四个 tab 切换不同类别。核心结构是顶部一排分类标签,下面一个列表。关键代码片段如下。
<view class="tabs"> <view wx:for="{{categories}}" wx:key="id" class="tab {{activeCategoryId === item.id ? 'active' : ''}}" bindtap="onCategoryTap" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />